The Service Layer
A use case expressed as a plain function that knows nothing about HTTP — which is what lets a job, a CLI and an endpoint share it.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Where does business logic go once more than one caller needs it?
Placing an order has to reserve stock, charge a card, write an order row and queue a confirmation email. Sales need it from the web app; support need it from an admin tool; a nightly job re-runs it for failed payments.
Write the whole sequence inside the HTTP handler. It is one place, it is easy to read top to bottom, and when the admin tool needs it, copy the function and adjust it.
The copies drift. Six weeks later the admin path still decrements stock before the charge and the web path does it after, so an admin-created order that fails payment silently loses inventory.
- The copies drift. Six weeks later the admin path still decrements stock before the charge and the web path does it after, so an admin-created order that fails payment silently loses inventory.
- A bug fix lands in one copy. Nobody knows the other exists because it is called
adminCreateOrderand lives in a different directory. - The logic cannot be tested without constructing a fake
reqandres. So it is tested through the HTTP layer, slowly, and the interesting branches — payment declined, stock gone — are never covered because they need HTTP fixtures nobody wants to write. - The transaction boundary is wherever someone happened to put
BEGIN. In the handler copy it wraps the payment call; in the job copy it does not. One of those is holding a pooled connection across a third-party network call (External Calls Inside a Transaction).
What is actually happening
- A service is a plain function or object that takes domain values and returns domain values. No
req, nores, no status codes, noContent-Type. That single restriction is what makes it callable from anywhere. - It owns ordering: which step happens first, what is compensated if a later step fails, what is deferred to a job.
- It owns the transaction boundary — the unit of work is a property of the use case, not of a route or a table (Where the Transaction Boundary Goes).
- It is the natural place for the invariant that spans records: "an order may not exist without at least one line", "a project cannot exceed the plan's seat count".
- What it deliberately does *not* own: parsing, status codes, serialization shape, or authentication. Those are transport concerns and belong above it (What a Handler Is Responsible For).
The signature is the whole design
Everything useful about a service layer follows from one property: nothing in its signature comes from HTTP. Once that is true, the nightly job, the admin tool, the queue consumer and the test all call the same function with the same arguments, and there is exactly one place where the order of operations is written down.
Once it is false — once a req or a res or an HttpError appears — every non-HTTP caller has to construct or catch transport objects, and in practice they stop calling it and copy it instead. The drift that follows is not a discipline problem; it is what the signature made easy.
1// application layer: no req, no res, no status codes2type PlaceOrder = { customerId: CustomerId; items: LineItem[]; idempotencyKey: string }3type PlaceOrderResult =4 | { ok: true; order: Order }5 | { ok: false; reason: 'insufficient_stock' | 'payment_declined' | 'duplicate' }6 7async function placeOrder(deps: Deps, cmd: PlaceOrder): Promise<PlaceOrderResult> {8 const charge = await deps.payments.charge(cmd) // external call, OUTSIDE the transaction9 if (!charge.ok) return { ok: false, reason: 'payment_declined' }10 11 return deps.db.transaction(async (tx) => { // the service owns the unit of work12 const reserved = await deps.stock.reserve(tx, cmd.items)13 if (!reserved) return { ok: false, reason: 'insufficient_stock' }14 const order = await deps.orders.insert(tx, cmd, charge.id)15 await deps.outbox.append(tx, 'order.placed', order.id)16 return { ok: true, order }17 })18}19 20// transport layer: the only file that knows what 409 means21app.post('/orders', async (req, res) => {22 const cmd = parsePlaceOrder(req) // untrusted -> typed23 const result = await placeOrder(deps, cmd)24 if (result.ok) return res.status(201).json(toOrderResponse(result.order))25 res.status(STATUS[result.reason]).json({ error: result.reason })26})The failure cases are values, not exceptions, so the job that calls placeOrder handles payment_declined without importing an HTTP library. The external charge sits outside db.transaction on purpose — inside it, the pooled connection is held for the provider's p99 (External Calls Inside a Transaction).
How to group services, and what each grouping costs
There is no single right granularity, and the argument about it is usually an argument about team size in disguise. What matters is that a reader can find "the place where an order is placed" without searching, and that the file they find does not also contain forty unrelated operations.
The failure mode at one end is a god service — OrderService with thirty public methods and eight injected dependencies, where every team touches the same file. At the other end it is a directory of two hundred one-method classes, where following a feature means opening eleven of them.
What is the unit that a reader opens when they want to understand a feature?
when One caller, one engineer, a use case that fits on a screen. The overwhelmingly common correct answer for a CRUD route.
cost The second caller copies it. You are betting there will not be one.
when Several entry points, and you want the file tree to name the operations.
cost Shared setup gets passed around explicitly or duplicated; many small files.
when The operations genuinely share state and invariants — orders, their lines, their status transitions.
cost Grows into a god object unless someone actively splits it. Injected dependency count is the early warning.
when Feature-organised codebases where a module exposes a small public surface and hides its internals (Alternatives to Layering).
cost The facade becomes a translation layer of its own; cross-feature calls need a policy or they become a graph.
when Rails/Django-shaped codebases, or a rich domain where the rule belongs with the data.
cost The model acquires persistence, serialization and business concerns at once — the fat-model mirror of Fat Controllers.
What the service must not know
The rule that keeps a service layer useful is a dependency-direction rule, not a naming rule. Transport may know about the application; the application may not know about transport. Every leak in the wrong direction removes a caller.
The practical test: could you call this from a queue consumer with no HTTP request in scope, and from a test with no server running? If not, name the thing that stops you. It is almost always a status code, a session lookup, or a header read.
- No status codes.
409is a fact about HTTP, not about stock. - No
req.headers,req.cookiesor session lookups — pass an already-resolved actor (Request Context Propagation). - No serialization decisions. Which fields the client sees is an API concern (Schema Leakage).
- No knowledge of *which* database. That is the layer below (The Repository Layer).
- No
process.envreads at call time — configuration arrives at construction (Dependency Management Without the Container).
How to build it
Most important first.
- Name services after use cases, not nouns.
placeOrder,cancelSubscription,inviteMember— each with one entry point, so "what happens when an order is placed" has one answer. - Take typed domain arguments, not the request object. A service whose signature is
(req)is a handler wearing a different filename (Parse, Do Not Validate). - Return domain results, including domain *failures*.
{ ok: false, reason: 'insufficient_stock' }is transport-agnostic;throw new HttpError(409)is not, and it forces every non-HTTP caller to catch HTTP. - Put the transaction in the service, not in the repository. The repository does not know that these three writes are one unit (The Repository Layer).
- Let the service stay small when the use case is small. A service that only forwards to one repository call is worth writing when it is the shared entry point, and worth deleting when it is not (When the Repository Is Just Indirection).
What can go wrong
- Services that call other services in a cycle:
OrderServicecallsInvoiceServicecallsOrderService. In an event-driven variant this becomes an infinite loop with a queue attached. - The service grows to 900 lines because "place an order" acquired eleven optional behaviours. The extraction that fixed duplication becomes the thing nobody wants to touch.
- A service that takes an optional
transactionparameter, which half the callers pass. The half that do not are silently outside the unit of work. - Transport leaking in through the back door: the service returns an object shaped exactly like the JSON response, so changing the API shape now changes business logic (Three Models, Not One).
- Two
placeOrdercalls for the last unit of stock both readquantity = 1and both decide it is available. The check-then-write gap is inside the service and only a database-level guarantee closes it (Atomic Operations, Database Constraints). - A service that reads, calls a payment provider, then writes holds an inconsistent view for the whole duration of the external call. Anything that changed meanwhile is invisible to it (Optimistic Concurrency).
- The service is where object-level authorization most often goes missing, because the handler checked "is this user logged in" and the service assumed someone else checked "may this user touch this order" (Object-Level Authorization).
- Decide once whether authorization lives in the handler or the service and enforce it consistently. A service reachable from a job, a CLI and an endpoint has three entry points and only one of them has a session (Where the Check Belongs).
- A service taking
tenantIdas a parameter is only as safe as its least careful caller. Prefer deriving it from an explicit, already-authenticated context object (Tenant Isolation).
- "Every handler needs a service." A handler that reads one row by id and returns it does not benefit from a passthrough. Extract on the second caller, not on principle.
- "The service layer makes the code testable." It makes it *callable*. Tests still have to be written, and the interesting ones still need a real database (Test Against the Real Database).
- "Business logic means validation." Format checks are transport work (Transport Validation); the service owns rules that need loaded state (Business Validation).
- "Services must be classes." The unit is the use case. A module of exported functions is a service layer.
Operating it
- Emit one metric per use case —
order.placed,order.place_failed{reason}— rather than only per route. Business-named counters survive URL changes and answer product questions the route metric cannot. - Open a span named after the service function, so a trace reads
POST /orders -> placeOrder -> reserveStock -> chargeCardinstead of one flat handler span (Tracing From the Backend's Side). - Log the domain failure reason as a field, not as free text in a message, so declines are countable (Structured Logging).
- At 10x traffic the service layer changes nothing about throughput — it is the same code with a name. What it changes is your ability to move a step out of the request path once the payment call becomes the p99 (Request or Background?).
- At 10x team size it changes a great deal: it is the difference between one definition of "place an order" and one per team.
- If the service later becomes a separate deployable, the seam is already there. That is a benefit worth having and a terrible reason to introduce services now (Microservices).
- Indirection. Reading a feature now means opening two files instead of one, and the second file is often thin.
- A shared service acquires flags. Every caller that needs a slightly different behaviour adds a parameter, and the union of those parameters is worse than two honest copies would have been.
- The domain-result convention (returning failures instead of throwing) costs discipline at every call site, and one caller that ignores the result reintroduces the bug you were preventing.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe idea — a callable unit of business logic with no transport types in its signature — holds in any language and framework.
- SCALE-SPECIFICFlips on number of callers and number of engineers, not on traffic. With one caller and two engineers a service layer is a second file to open for no benefit. It starts paying the moment a second entry point (a job, a CLI, an admin route, a webhook consumer) needs the same sequence, and it becomes load-bearing above roughly ten engineers, where "where does this rule live" stops being answerable by asking the person next to you.
- FRAMEWORK-SPECIFICRails and Django put behaviour on the model by default, so the "service" is often a model method and the pressure is toward fat models rather than fat controllers; Spring and NestJS supply a
@Serviceconstruct so the layer exists whether or not it earns its place. Express, FastAPI and Go's net/http supply nothing, so the layer only exists if you build it.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — what a unit test of a service can and cannot prove once the database is mocked out.