Skip to main content
The service layer sits between the domain and the adapters. It declares what the application can do (use cases), what it needs from infrastructure (ports), and how callers send requests (input contracts). Nothing here references SQLite, HTTP, or any concrete storage mechanism — those belong to the adapters ring.

CoffeeOrderApp Service

CoffeeOrderApp is the public facade for the entire backend. Presentation-layer code (HTTP handlers, CLI commands, MCP tools) depends only on this single service.
export class CoffeeOrderApp extends ServiceMap.Service<
  CoffeeOrderApp,
  {
    readonly listMenu: () => Effect.Effect<Menu, InternalAppError>;
    readonly placeOrder: (
      input: PlaceOrderRequest,
    ) => Effect.Effect<CoffeeOrder, DrinkNotFoundError | InvalidOrderInputError | InternalAppError>;
    readonly getOrder: (
      orderId: OrderId,
    ) => Effect.Effect<CoffeeOrder, OrderNotFoundError | InternalAppError>;
    readonly listOrders: (
      input: ListOrdersRequest,
    ) => Effect.Effect<CoffeeOrders, InvalidOrderInputError | InternalAppError>;
    readonly startBrewing: (
      orderId: OrderId,
    ) => Effect.Effect<
      CoffeeOrder,
      InvalidOrderStatusTransitionError | OrderNotFoundError | InternalAppError
    >;
    readonly markReady: (
      orderId: OrderId,
    ) => Effect.Effect<
      CoffeeOrder,
      InvalidOrderStatusTransitionError | OrderNotFoundError | InternalAppError
    >;
    readonly pickUpOrder: (
      orderId: OrderId,
    ) => Effect.Effect<
      CoffeeOrder,
      InvalidOrderStatusTransitionError | OrderNotFoundError | InternalAppError
    >;
    readonly cancelOrder: (
      orderId: OrderId,
    ) => Effect.Effect<
      CoffeeOrder,
      InvalidOrderStatusTransitionError | OrderNotFoundError | InternalAppError
    >;
  }
>()("effect-v4-onion/service/CoffeeOrderApp") {
  static readonly layer = Layer.effect(
    this,
    Effect.gen(function* () {
      const menuRepository = yield* MenuRepository;
      const orderIdGenerator = yield* OrderIdGenerator;
      const orderRepository = yield* OrderRepository;
      // binds each use-case function to the resolved adapter instances
      return CoffeeOrderApp.of({ /* ... */ });
    }),
  );
}
CoffeeOrderApp.layer resolves the three port services from the Effect runtime and closes over them when binding each use case. The adapter implementations flow in through Layer composition at the call site — CoffeeOrderApp.layer itself never references any concrete adapter.

Ports

Ports are abstract contracts defined as ServiceMap.Service classes. They form the inner boundary of the adapters ring: any adapter that satisfies the port type can be swapped in without changing a single line of use-case code.
All persistence operations return PersistenceError rather than a database-specific error type. Use cases map PersistenceError to InternalAppError before surfacing it — domain and service code never sees a SQL or I/O error directly.

Use Cases

Each use case is an Effect.fn-wrapped generator function. The function name (e.g. "CoffeeOrders.placeOrder") appears in Effect traces. Required ports are pulled from the Effect environment with yield* — the TypeScript type system enforces that callers provide all required services.

listMenu

export const listMenu = Effect.fn("CoffeeOrders.listMenu")(function* (): Effect.fn.Return<
  Menu,
  InternalAppError,
  MenuRepository
> {
  const menuRepository = yield* MenuRepository;
  return yield* menuRepository.list.pipe(
    Effect.mapError(internalAppErrorFromPersistence("Unable to load menu right now")),
  );
});

placeOrder

placeOrder is the most complex use case. It validates every field, looks up the menu item, computes the price, generates an order ID, and persists the order.
export const placeOrder = Effect.fn("CoffeeOrders.placeOrder")(function* (
  request: PlaceOrderRequest,
): Effect.fn.Return<
  CoffeeOrder,
  DrinkNotFoundError | InvalidOrderInputError | InternalAppError,
  MenuRepository | OrderIdGenerator | OrderRepository
> {
  const orderIdGenerator = yield* OrderIdGenerator;
  const menuRepository = yield* MenuRepository;
  const orderRepository = yield* OrderRepository;

  const customerName = yield* validateCustomerName(request.customerName);

  const menuItem = yield* menuRepository.findById(request.drinkId).pipe(
    Effect.mapError(internalAppErrorFromPersistence("Unable to place order right now")),
    Effect.flatMap(
      Option.match({
        onNone: () => Effect.fail(new DrinkNotFoundError({ drinkId: request.drinkId })),
        onSome: Effect.succeed,
      }),
    ),
  );

  const size = yield* validateSize(request.size);
  const milk = yield* resolveMilk(menuItem, request.milk);
  const temperature = yield* resolveTemperature(menuItem, request.temperature);
  const shots = yield* resolveShots(menuItem, request.shots);

  const id = yield* orderIdGenerator.next;
  const createdAt = yield* DateTime.now;

  const order: CoffeeOrder = {
    id, customerName, drinkId: menuItem.id, drinkName: menuItem.name,
    size, milk, temperature, shots,
    status: "pending",
    priceCents: calculatePriceCents(menuItem, size, shots),
    createdAt,
  };

  return yield* orderRepository
    .save(order)
    .pipe(Effect.mapError(internalAppErrorFromPersistence("Unable to place order right now")));
});

getOrder and listOrders

export const getOrder = Effect.fn("CoffeeOrders.getOrder")(function* (
  orderId: OrderId,
): Effect.fn.Return<CoffeeOrder, OrderNotFoundError | InternalAppError, OrderRepository> {
  const orderRepository = yield* OrderRepository;
  return yield* orderRepository.getById(orderId).pipe(
    Effect.mapError(internalAppErrorFromPersistence("Unable to load order right now")),
    Effect.flatMap(
      Option.match({
        onNone: () => Effect.fail(new OrderNotFoundError({ orderId })),
        onSome: Effect.succeed,
      }),
    ),
  );
});
export const listOrders = Effect.fn("CoffeeOrders.listOrders")(function* (
  request: ListOrdersRequest,
): Effect.fn.Return<CoffeeOrders, InvalidOrderInputError | InternalAppError, OrderRepository> {
  const orderRepository = yield* OrderRepository;

  if (request.status === undefined) {
    return yield* orderRepository
      .list()
      .pipe(Effect.mapError(internalAppErrorFromPersistence("Unable to list orders right now")));
  }

  if (!isOrderStatus(request.status)) {
    return yield* new InvalidOrderInputError({
      message: `status "${request.status}" is not supported`,
    });
  }

  return yield* orderRepository
    .list({ status: request.status })
    .pipe(Effect.mapError(internalAppErrorFromPersistence("Unable to list orders right now")));
});

Status-transition use cases

startBrewing, markReady, pickUpOrder, and cancelOrder all delegate to a shared updateOrderStatus helper that checks canTransitionTo before persisting.
const updateOrderStatus = Effect.fn("CoffeeOrders.updateOrderStatus")(function* (
  orderId: OrderId,
  to: OrderStatus,
): Effect.fn.Return<
  CoffeeOrder,
  InvalidOrderStatusTransitionError | OrderNotFoundError | InternalAppError,
  OrderRepository
> {
  const orderRepository = yield* OrderRepository;
  const order = yield* orderRepository.getById(orderId).pipe(/* ... */);

  if (!canTransitionTo(order.status, to)) {
    return yield* new InvalidOrderStatusTransitionError({ orderId, from: order.status, to });
  }

  return yield* orderRepository.save({ ...order, status: to }).pipe(/* ... */);
});

export const startBrewing = Effect.fn("CoffeeOrders.startBrewing")(function* (orderId: OrderId) {
  return yield* updateOrderStatus(orderId, "brewing");
});
// markReady → "ready", pickUpOrder → "picked-up", cancelOrder → "cancelled"

Input Contracts

PlaceOrderRequest and ListOrdersRequest are the schema-validated shapes the service layer accepts from callers. Optional fields use Schema.optionalKey so missing keys are distinguished from explicit undefined.
export const PlaceOrderRequestSchema = Schema.Struct({
  customerName: Schema.String,
  drinkId: Schema.String,
  size: Schema.String,
  milk: Schema.optionalKey(Schema.String),
  temperature: Schema.optionalKey(Schema.String),
  shots: Schema.optionalKey(Schema.Int),
  notes: Schema.optionalKey(Schema.String),
}).annotate({ identifier: "PlaceOrderRequest" });
export type PlaceOrderRequest = typeof PlaceOrderRequestSchema.Type;

export const ListOrdersRequestSchema = Schema.Struct({
  status: Schema.optionalKey(Schema.String),
}).annotate({ identifier: "ListOrdersRequest" });
export type ListOrdersRequest = typeof ListOrdersRequestSchema.Type;
placeOrder accepts raw strings for size, milk, and temperature rather than the stricter domain union types. The use case itself validates and narrows them, returning InvalidOrderInputError with a descriptive message when a value is unrecognised.

Typed Error Flow

Effect encodes errors in the second type parameter of Effect<A, E, R>. The return types above make every failure mode explicit — a caller that handles DrinkNotFoundError without also handling InvalidOrderInputError produces a compile error. InternalAppError is the opaque wrapper for any PersistenceError that escapes from an adapter; it prevents internal storage details from leaking into the public API surface.
ErrorRaised by
DrinkNotFoundErrorplaceOrder when drinkId is absent from the menu
InvalidOrderInputErrorplaceOrder, listOrders for bad input values
OrderNotFoundErrorgetOrder, startBrewing, markReady, pickUpOrder, cancelOrder
InvalidOrderStatusTransitionErrorstartBrewing, markReady, pickUpOrder, cancelOrder
InternalAppErrorAny use case when a persistence operation fails

Build docs developers (and LLMs) love