You've seen it before. A UserService class that started as a clean coordination point now sprawls across two thousand lines. It validates business rules, transforms data, enforces policies, manages transactions, and sends notifications. It has become the application.
The service layer pattern is one of the most widely adopted—and most widely misunderstood—architectural patterns in enterprise software. Its purpose is deceptively simple: coordinate operations across boundaries. Manage transactions. Enforce security. Delegate to domain objects. That's it.
Yet in practice, service layers become gravitational wells that absorb every piece of logic developers don't know where else to put. The result is a codebase where domain objects are hollow shells and services are monolithic scripts. Understanding what belongs in a service layer—and what emphatically does not—is one of the most consequential design decisions you'll make.
Layer Responsibilities: The Orchestrator, Not the Decision Maker
A well-designed service layer acts like a conductor in an orchestra. The conductor doesn't play any instrument. They coordinate timing, signal transitions, and ensure everyone performs in harmony. Similarly, a service method should coordinate infrastructure concerns and delegate business decisions to the objects that own them.
What belongs in a service layer? Transaction demarcation. Authorization checks—can this user invoke this operation? Cross-cutting concerns like logging and auditing. Fetching and persisting domain objects through repositories. Dispatching events or notifications after an operation completes. These are all coordination responsibilities. They answer how an operation is executed within the system's infrastructure, not what the operation means.
What doesn't belong? Business rules. Validation of domain invariants. Conditional logic that determines whether an order qualifies for a discount or whether a patient's prescription conflicts with existing medications. These decisions belong to domain objects—entities, value objects, and domain services—because that's where the knowledge lives and where it can be tested, reused, and evolved independently.
A useful heuristic: if you removed your service layer entirely, would your domain objects still enforce all business rules? If yes, your service layer is doing its job. If no, business logic has leaked into orchestration code, and you've coupled your rules to your infrastructure. That coupling will cost you every time you need to invoke the same logic from a different entry point—a CLI tool, a background job, a different API endpoint.
TakeawayA service layer should be thin enough that you could rewrite it in a weekend. If you can't, it's probably making decisions that belong to your domain.
The Anemic Service Trap: When Services Eat Your Domain
Martin Fowler called the Anemic Domain Model an anti-pattern for good reason. It looks like object-oriented design on the surface—you have Order, Customer, Invoice classes—but they're nothing more than data structures with getters and setters. All the interesting behavior lives in service classes that manipulate these passive containers.
This happens gradually. A developer adds a small business rule to OrderService because it needs data from the repository. Another developer adds discount calculation there because the service already handles orders. Soon, OrderService contains every rule about what an order can and cannot do, while the Order class itself is a glorified dictionary. You've written procedural code wearing an object-oriented costume.
The consequences compound. You lose encapsulation—any code with access to the setters can put an Order into an invalid state, because the Order doesn't protect its own invariants. You lose discoverability—to understand order behavior, you search across multiple services rather than reading one cohesive class. You lose polymorphism—you can't leverage different order types through inheritance or interfaces because the types have no behavior to vary.
The fix requires discipline more than cleverness. When you're about to write an if statement in a service method that evaluates a domain concept, pause. Ask: could this logic live on the domain object itself? If an order shouldn't be cancelled after shipping, that rule belongs on Order.cancel(), not in OrderService.cancelOrder(). Push behavior toward the data it operates on. Let your services become the thin coordination layer they were always meant to be.
TakeawayIf your domain objects have no behavior worth testing, your service layer has stolen their purpose. Rich domain objects protect their own rules; anemic ones rely on external code to keep them honest.
Transaction Scripting: Knowing When Simple Is Enough
Here's the nuance that dogmatic design advice often misses: not every application needs a rich domain model. Martin Fowler's Patterns of Enterprise Application Architecture describes Transaction Script as a perfectly valid pattern where each business operation is handled by a single procedure. For straightforward CRUD applications, ETL pipelines, or simple workflow automation, a service layer that contains procedural logic is not an anti-pattern—it's the right tool.
The distinction hinges on domain complexity. If your business rules are simple and unlikely to grow—create a record, validate a few fields, save it, send a notification—a transaction script in a service class is clear, easy to follow, and easy to maintain. Introducing entities with encapsulated behavior and domain events would be over-engineering. You'd pay the cost of abstraction without reaping the benefit.
The danger is misjudging where you are on this spectrum. Applications that start simple often grow complex. The sign that you've outgrown transaction scripts is duplication of conditional logic. When the same business rule appears in multiple service methods—or worse, slightly different versions of the same rule—it's time to extract domain objects that own those rules. The cost of refactoring later is real but manageable. The cost of prematurely building a rich domain model for a simple CRUD app is wasted complexity.
Think of it as a progression. Start with transaction scripts when the domain is simple and well-understood. As conditional logic multiplies and rules begin duplicating, graduate to a richer domain model by extracting behavior into entities and value objects. Your service layer then naturally thins out as it delegates more to the domain. This isn't failure—it's evolution. The key is recognizing the transition point and having the discipline to refactor rather than continuing to pile logic into services.
TakeawaySimple domains deserve simple solutions. The architectural mistake isn't starting with transaction scripts—it's refusing to evolve past them when your domain demands more.
The service layer pattern is easy to describe and hard to practice. Its entire value depends on restraint—the discipline to coordinate without deciding, to delegate without absorbing, to remain thin while everything around it grows.
The questions worth asking are simple. Does this logic describe what the business means, or how the system executes? Could my domain objects enforce their rules without this service? Am I writing procedural code disguised as object-oriented design?
A well-built service layer is almost boring to read. It fetches, delegates, persists, and notifies. The interesting work happens in the domain. And that's exactly the point.