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.
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"
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.
| Error | Raised by |
|---|
DrinkNotFoundError | placeOrder when drinkId is absent from the menu |
InvalidOrderInputError | placeOrder, listOrders for bad input values |
OrderNotFoundError | getOrder, startBrewing, markReady, pickUpOrder, cancelOrder |
InvalidOrderStatusTransitionError | startBrewing, markReady, pickUpOrder, cancelOrder |
InternalAppError | Any use case when a persistence operation fails |