Skip to content

Tutorial 5: Service Layer

Duration: 1.5 hours
Prerequisites: Tutorials 1-4 completed
What you'll learn: Service actions, commands/queries, authorization, API contracts, rate limiting, and composition

Overview

The service layer exposes your domain logic to external consumers (REST APIs, GraphQL, CLI). USL provides:

  • Actions: Commands (mutating) and queries (read-only)
  • Contracts: Strong input/output types with validation
  • Authorization: Role and permission-based access control
  • Rate Limiting: Automatic throttling with configurable policies
  • Events: Pub/sub for async notifications
  • Composition: Chain actions with transaction guarantees
  • Code Generation: Automatic REST/GraphQL/gRPC endpoints

Project: Order Management API

We'll build a production-ready order management API with authentication, authorization, and monitoring.

Step 1: Basic Service Actions

Create the order service:

service OrderService {

  // Command: Creates a new order
  action CreateOrder(input: CreateOrderInput) -> Result[Order, OrderError]
    requires authenticated
    permits CreateOrder
    rate_limit 10 per minute
    effects {
      // Validate input
      require input.items.len() > 0, "Order must have at least one item"
      require input.shippingAddress.isValid(), "Invalid shipping address"

      // Calculate pricing
      let subtotal = input.items.map(|item| item.price * item.quantity).sum()
      let tax = calculateTax(subtotal, input.shippingAddress.state)
      let shipping = calculateShipping(input.items, input.shippingAddress)
      let total = subtotal + tax + shipping

      // Create order entity
      let order = Order {
        id: generateOrderId(),
        customerId: context.user.id,
        items: input.items.map(|item| LineItem {
          id: generateLineItemId(),
          orderId: order.id,
          productId: item.productId,
          quantity: item.quantity,
          price: item.price
        }),
        subtotal: subtotal,
        tax: tax,
        shippingCost: shipping,
        totalAmount: total,
        status: OrderStatus.Pending,
        shippingAddress: input.shippingAddress,
        billingAddress: input.billingAddress,
        createdAt: now(),
        confirmedAt: None,
        shippedAt: None,
        deliveredAt: None
      }

      // Persist order
      let saved = persist(order)?

      // Emit event for downstream processing
      emit OrderCreated {
        orderId: saved.id,
        customerId: saved.customerId,
        totalAmount: saved.totalAmount,
        timestamp: now()
      }

      // Schedule timeout for unconfirmed orders
      schedule(72 hours) {
        if !orderConfirmed(saved.id) {
          cancelOrder(saved.id, "Order expired")
        }
      }

      Ok(saved)
    }

  // Query: Get order by ID
  action GetOrder(orderId: OrderId) -> Result[Order, OrderError]
    requires authenticated
    permits ViewOrder
    effects {
      let order = fetch Order where id == orderId
        .ok_or(OrderError.NotFound)?

      // Authorization: Can only view own orders unless admin
      require 
        order.customerId == context.user.id || 
        context.user.hasRole("Admin"),
        "Unauthorized to view this order"

      Ok(order)
    }

  // Query: List user's orders with pagination
  action ListMyOrders(
    page: Int,
    pageSize: Int,
    filters: Option[OrderFilters]
  ) -> Result[PaginatedOrders, OrderError]
    requires authenticated
    permits ViewOrder
    rate_limit 60 per minute
    effects {
      require page >= 0, "Page must be non-negative"
      require pageSize > 0 && pageSize <= 100, "Page size must be 1-100"

      let query = fetch Order 
        where customerId == context.user.id

      // Apply optional filters
      let filtered = match filters {
        Some(f) => {
          query
            .filter(|o| f.status.map_or(true, |s| o.status == s))
            .filter(|o| f.minAmount.map_or(true, |min| o.totalAmount >= min))
            .filter(|o| f.maxAmount.map_or(true, |max| o.totalAmount <= max))
            .filter(|o| f.dateFrom.map_or(true, |from| o.createdAt >= from))
            .filter(|o| f.dateTo.map_or(true, |to| o.createdAt <= to))
        }
        None => query
      }

      // Get paginated results
      let total = filtered.count()
      let orders = filtered
        .order_by(|o| o.createdAt desc)
        .skip(page * pageSize)
        .take(pageSize)
        .collect()

      Ok(PaginatedOrders {
        orders: orders,
        page: page,
        pageSize: pageSize,
        total: total,
        hasMore: (page + 1) * pageSize < total
      })
    }

  // Command: Confirm order (after payment)
  action ConfirmOrder(orderId: OrderId) -> Result[Order, OrderError]
    requires authenticated
    permits ConfirmOrder
    effects {
      let order = fetch Order where id == orderId
        .ok_or(OrderError.NotFound)?

      // Authorization check
      require order.customerId == context.user.id, "Unauthorized"

      // State machine transition
      order.confirm()?  // Calls OrderLifecycle.confirm transition

      emit OrderConfirmed {
        orderId: order.id,
        confirmedAt: now()
      }

      Ok(order)
    }

  // Command: Cancel order
  action CancelOrder(
    orderId: OrderId,
    reason: String
  ) -> Result[Order, OrderError]
    requires authenticated
    permits CancelOrder
    effects {
      let order = fetch Order where id == orderId
        .ok_or(OrderError.NotFound)?

      // Authorization: owner or admin
      require 
        order.customerId == context.user.id || 
        context.user.hasRole("Admin"),
        "Unauthorized"

      // Check if cancellable
      require 
        order.status in [OrderStatus.Pending, OrderStatus.Confirmed],
        "Cannot cancel order in current status"

      // Execute cancellation
      order.cancel()?
      order.cancellationReason = Some(reason)
      persist(order)?

      // Refund if payment was captured
      if order.status == OrderStatus.Confirmed {
        refundPayment(order)?
      }

      emit OrderCancelled {
        orderId: order.id,
        reason: reason,
        cancelledAt: now()
      }

      Ok(order)
    }

  // Query: Search orders (admin only)
  action SearchOrders(
    query: String,
    filters: OrderFilters
  ) -> Result[List[Order], OrderError]
    requires authenticated
    permits AdminSearchOrders
    requires hasRole("Admin")
    rate_limit 30 per minute
    effects {
      let results = search Order {
        text: query,
        fields: ["id", "customerId", "items"],
        filters: {
          status: filters.status,
          dateRange: (filters.dateFrom, filters.dateTo),
          amountRange: (filters.minAmount, filters.maxAmount)
        },
        limit: 100
      }

      Ok(results)
    }
}

Step 2: Input/Output Contracts

Define strongly-typed contracts:

// Input contracts
record CreateOrderInput {
  items: List[OrderItemInput]
  shippingAddress: Address
  billingAddress: Address
  paymentMethodId: String

  invariant has_items {
    len(this.items) > 0
  }
}

record OrderItemInput {
  productId: ProductId
  quantity: Int
  price: Money

  invariant positive_quantity {
    this.quantity > 0
  }

  invariant positive_price {
    this.price.amount > 0.0
  }
}

record Address {
  line1: String
  line2: Option[String]
  city: String
  state: String
  zipCode: String
  country: String

  invariant valid_zip {
    len(this.zipCode) >= 5
  }

  function isValid() -> Bool {
    !this.line1.is_empty() &&
    !this.city.is_empty() &&
    !this.state.is_empty() &&
    this.zipCode.matches(r"^\d{5}(-\d{4})?$")
  }
}

record OrderFilters {
  status: Option[OrderStatus]
  dateFrom: Option[Timestamp]
  dateTo: Option[Timestamp]
  minAmount: Option[Money]
  maxAmount: Option[Money]
}

// Output contracts
record PaginatedOrders {
  orders: List[Order]
  page: Int
  pageSize: Int
  total: Int
  hasMore: Bool
}

// Error types
enum OrderError {
  NotFound,
  Unauthorized,
  InvalidInput(String),
  InsufficientStock(List[ProductId]),
  PaymentFailed(String),
  InvalidTransition(String),
  RateLimitExceeded
}

Step 3: Authorization

Define permissions and policies:

policy OrderServicePolicy {
  // Role definitions
  role Customer {
    description: "Regular customer"
  }

  role Admin {
    description: "Administrator with full access"
    inherits: [Customer]
  }

  role Support {
    description: "Customer support staff"
    inherits: [Customer]
  }

  // Permission definitions
  permission CreateOrder {
    description: "Can create new orders"
    granted_to: [Customer]
  }

  permission ViewOrder {
    description: "Can view order details"
    granted_to: [Customer]
  }

  permission ConfirmOrder {
    description: "Can confirm pending orders"
    granted_to: [Customer]
  }

  permission CancelOrder {
    description: "Can cancel orders"
    granted_to: [Customer, Support, Admin]
  }

  permission AdminSearchOrders {
    description: "Can search all orders"
    granted_to: [Admin, Support]
  }

  // Resource-level authorization
  rule "customers can only view own orders" {
    action: ViewOrder,
    resource: Order,
    condition: resource.customerId == context.user.id || context.user.hasRole("Admin")
  }

  rule "support can view any order" {
    action: ViewOrder,
    resource: Order,
    condition: context.user.hasRole("Support")
  }

  rule "only admin can search all orders" {
    action: AdminSearchOrders,
    condition: context.user.hasRole("Admin")
  }
}

Step 4: Rate Limiting

Configure rate limiting policies:

service OrderService {
  // Per-user rate limits
  rate_limit_policy default {
    window: 1 minute
    max_requests: 60
    key: context.user.id
  }

  // Endpoint-specific limits
  action CreateOrder(input: CreateOrderInput) -> Result[Order, OrderError]
    rate_limit 10 per minute per user
    rate_limit 1000 per minute globally
    burst: 5  // Allow short bursts
    effects { /* ... */ }

  // Premium tier bypass
  action GetOrder(orderId: OrderId) -> Result[Order, OrderError]
    rate_limit match context.user.tier {
      "Free" => 60 per minute,
      "Premium" => 300 per minute,
      "Enterprise" => unlimited
    }
    effects { /* ... */ }
}

Step 5: Event-Driven Architecture

Publish and subscribe to events:

// Event definitions
event OrderCreated {
  orderId: OrderId
  customerId: UserId
  totalAmount: Money
  timestamp: Timestamp
}

event OrderConfirmed {
  orderId: OrderId
  confirmedAt: Timestamp
}

event OrderCancelled {
  orderId: OrderId
  reason: String
  cancelledAt: Timestamp
}

// Event subscribers
service NotificationService {
  subscribe OrderCreated => sendOrderConfirmationEmail
  subscribe OrderShipped => sendShippingNotification
  subscribe OrderDelivered => sendDeliveryNotification
  subscribe OrderCancelled => sendCancellationNotification

  action sendOrderConfirmationEmail(event: OrderCreated) -> Result[Unit, Error]
    effects {
      let order = fetch Order where id == event.orderId?
      let customer = fetch User where id == event.customerId?

      sendEmail {
        to: customer.email,
        subject: "Order Confirmation #${order.id}",
        template: "order_confirmation",
        data: {
          order: order,
          customer: customer
        }
      }?

      Ok(())
    }
}

service InventoryService {
  subscribe OrderConfirmed => reserveInventory
  subscribe OrderCancelled => releaseInventory

  action reserveInventory(event: OrderConfirmed) -> Result[Unit, Error]
    effects {
      let order = fetch Order where id == event.orderId?

      for item in order.items {
        let product = fetch Product where id == item.productId?

        require product.stock >= item.quantity,
          Error.InsufficientStock(item.productId)

        product.stock -= item.quantity
        product.reserved += item.quantity
        persist(product)?
      }

      emit InventoryReserved {
        orderId: order.id,
        items: order.items
      }

      Ok(())
    }
}

Step 6: Service Composition

Chain multiple actions with transaction guarantees:

service OrderService {
  // Composite action: Create and confirm order
  action CreateAndConfirmOrder(
    input: CreateOrderInput,
    paymentToken: String
  ) -> Result[Order, OrderError]
    requires authenticated
    permits CreateOrder, ConfirmOrder
    transactional  // All-or-nothing execution
    effects {
      // Step 1: Create order
      let order = CreateOrder(input)?

      // Step 2: Process payment
      let payment = processPayment {
        orderId: order.id,
        amount: order.totalAmount,
        token: paymentToken,
        customerId: context.user.id
      }?

      // Step 3: Confirm order (triggers state machine transition)
      let confirmed = ConfirmOrder(order.id)?

      // Step 4: Send confirmation
      sendOrderConfirmation(confirmed)?

      Ok(confirmed)
    }

  // Bulk action: Cancel multiple orders
  action BulkCancelOrders(
    orderIds: List[OrderId],
    reason: String
  ) -> Result[List[Order], OrderError]
    requires authenticated
    permits AdminCancelOrders
    requires hasRole("Admin")
    effects {
      let cancelled = orderIds.map(|id| {
        CancelOrder(id, reason)
      }).collect()?

      Ok(cancelled)
    }
}

Step 7: Testing Services

spec OrderServiceTests {
  test "can create order with valid input" {
    let user = createTestUser()
    let context = AuthContext { user: user }

    let input = CreateOrderInput {
      items: [
        OrderItemInput {
          productId: ProductId("prod_123"),
          quantity: 2,
          price: Money(50.00, "USD")
        }
      ],
      shippingAddress: createTestAddress(),
      billingAddress: createTestAddress(),
      paymentMethodId: "pm_test_123"
    }

    let result = OrderService.CreateOrder(input)
      .with_context(context)
      .execute()

    assert result.is_ok()
    let order = result.unwrap()
    assert order.customerId == user.id
    assert order.items.len() == 1
    assert order.status == OrderStatus.Pending
    assert eventEmitted(OrderCreated)
  }

  test "cannot create order without authentication" {
    let input = createValidOrderInput()

    expect_error(Unauthenticated) {
      OrderService.CreateOrder(input).execute()
    }
  }

  test "rate limiting enforced" {
    let user = createTestUser()
    let context = AuthContext { user: user }

    // Make 10 requests (limit per minute)
    for i in 0..10 {
      let result = OrderService.CreateOrder(createValidOrderInput())
        .with_context(context)
        .execute()
      assert result.is_ok()
    }

    // 11th request should fail
    let result = OrderService.CreateOrder(createValidOrderInput())
      .with_context(context)
      .execute()

    assert result.is_err()
    assert result.unwrap_err() == OrderError.RateLimitExceeded
  }

  test "customers can only view own orders" {
    let user1 = createTestUser()
    let user2 = createTestUser()

    let order = createOrderForUser(user1)

    // User1 can view
    let result1 = OrderService.GetOrder(order.id)
      .with_context(AuthContext { user: user1 })
      .execute()
    assert result1.is_ok()

    // User2 cannot view
    let result2 = OrderService.GetOrder(order.id)
      .with_context(AuthContext { user: user2 })
      .execute()
    assert result2.is_err()
    assert result2.unwrap_err() == OrderError.Unauthorized
  }
}

Step 8: Code Generation

Generate REST API:

usl codegen service OrderService --target rest --output ./api/

Generated code:

// Generated REST API (TypeScript/Express)
app.post('/orders', authenticate, async (req, res) => {
  try {
    const input = CreateOrderInput.parse(req.body);
    const result = await orderService.createOrder(input, req.user);
    res.json(result);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

app.get('/orders/:id', authenticate, async (req, res) => {
  try {
    const order = await orderService.getOrder(req.params.id, req.user);
    res.json(order);
  } catch (error) {
    if (error.type === 'NotFound') {
      res.status(404).json({ error: 'Order not found' });
    } else {
      res.status(400).json({ error: error.message });
    }
  }
});

What You've Learned

✅ Service action design (commands and queries)
✅ Input/output contracts with validation
✅ Permission-based authorization
✅ Rate limiting configuration
✅ Event-driven architecture
✅ Service composition with transactions
✅ Service testing strategies
✅ Code generation for REST APIs

Common Patterns

CQRS (Command Query Responsibility Segregation)

service OrderService {
  // Commands (write side)
  command CreateOrder(input: CreateOrderInput) -> OrderId { /* ... */ }
  command ConfirmOrder(id: OrderId) -> Unit { /* ... */ }

  // Queries (read side)
  query GetOrder(id: OrderId) -> Order { /* ... */ }
  query ListOrders(filters: OrderFilters) -> List[Order] { /* ... */ }
}

Idempotency

action CreateOrder(input: CreateOrderInput) -> Result[Order, OrderError]
  idempotency_key input.idempotencyKey
  effects {
    // Check if order already exists with this key
    let existing = fetch Order where idempotencyKey == input.idempotencyKey

    match existing {
      Some(order) => Ok(order),  // Return existing order
      None => {
        // Create new order
        let order = /* create order */
        persist(order)?
        Ok(order)
      }
    }
  }

Exercises

  1. Add Update Action: Create UpdateOrderShippingAddress action
  2. Add Batch Actions: Create BulkConfirmOrders for admin
  3. Add Export: Create ExportOrdersToCSV query
  4. Add Webhooks: Subscribe external systems to order events

Next Steps

Continue to Tutorial 6: Spec Testing to learn comprehensive testing strategies.

Additional Resources