Open a typical backend project, find the Service directory. A dozen classes, each with a few methods. Look carefully at the method bodies — most are one line: pass parameters to the Repository, pass the return value back to the Controller.

func (s *UserService) GetUser(id string) (*User, error) {
    return s.repo.FindByID(id)
}

func (s *UserService) ListUsers() ([]*User, error) {
    return s.repo.FindAll()
}

No logic, no orchestration, no side effects. The method signatures mirror the Repository's, with the only difference being an extra file and an extra stack frame.

But this raises a more interesting question: if no one was forced to write this code — if nobody required a Service class — would these methods exist? Probably not. They don't exist because the business needs them; they exist because "convention" needs them. Everyone on the team knows this layer does nothing, yet nobody stops to ask: when does the Service layer actually add value?

This question goes deeper than it looks. It's not about one language's habits or one framework's conventions — it's about the act of adding a layer of abstraction itself: when to add, when not to add, and what the cost of getting it wrong is.

Where the Service Layer Comes From

The Service layer wasn't invented by Spring.

Martin Fowler defined the Service Layer pattern in Patterns of Enterprise Application Architecture. Its original intent was clear: define a boundary between the domain layer and the presentation layer, encapsulating business logic so the same business operation can be reused across multiple presentation layers (web UI, REST API, CLI tool, message queue consumer).

The key point: the Service layer was designed to solve a specific problem — business logic needed to be reused by multiple interfaces. It's not an "architectural standard," it's a solution to a particular problem. If that problem doesn't exist (say, your system has one REST API and unlikely to add a CLI interface), part of the original motivation already weakens.

But in practice, this pattern was universally applied. Teams transformed it from "a tool for solving specific problems" into "the default structure for any backend system." When a pattern shifts from a conscious architectural decision to an unconscious default, degradation begins.

Degradation doesn't happen overnight. When a project starts, the Service layer might have real content — a few substantive business operations requiring orchestration. Then the project moves forward, a new entity is added, needs CRUD. Following the existing pattern, create Controller, Service, Repository classes. The Controller has parameter parsing, the Repository has SQL or ORM calls, the Service relays between them.

At this point the Service is already air. But nobody notices, because each class looks reasonable in isolation — "this method queries users, it belongs in the Service layer." The reasonableness is distributed across files; the unreasonableness is global — only by listing all Service methods together would you see that more than half are pure relays.

Orchestration Is the Core Value

Stepping outside CRUD, what is the Service layer's actual value?

Consider placing an order. It's not a single database write. It's a business process: check inventory sufficiency, calculate discounts based on pricing rules, lock the user's selected coupon, create the order record, deduct inventory, write an audit log. Six steps involving three data sources (inventory, orders, coupons), each with independent failure modes, any step failing requiring a decision on whether to roll back previous steps and how.

This logic must have a clear location. It can't live in the data access layer — the Repository shouldn't know how discounts are calculated or when coupons are locked. It can't live in the transport layer — the Handler shouldn't know the field structure of the inventory table or the write rules for audit logs. It needs its own place: a layer responsible for orchestrating business processes, applying business constraints, managing operation boundaries. That's the Service layer's reason to exist.

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
    stock, err := s.inventoryRepo.GetStock(ctx, req.ProductID)
    if err != nil {
        return nil, fmt.Errorf("check inventory: %w", err)
    }
    if stock.Available < req.Quantity {
        return nil, ErrInsufficientStock
    }

    discount, err := s.pricingCalc.Apply(ctx, req.ProductID, req.Quantity, req.CouponCode)
    if err != nil {
        return nil, fmt.Errorf("calculate pricing: %w", err)
    }

    if req.CouponCode != "" {
        if err := s.couponRepo.Lock(ctx, req.CouponCode, req.UserID); err != nil {
            return nil, fmt.Errorf("lock coupon: %w", err)
        }
    }

    order := &Order{
        UserID:    req.UserID,
        ProductID: req.ProductID,
        Quantity:  req.Quantity,
        Amount:    stock.UnitPrice*req.Quantity - discount,
        Status:    "pending",
    }
    if err := s.orderRepo.Create(ctx, order); err != nil {
        return nil, fmt.Errorf("create order: %w", err)
    }

    if err := s.inventoryRepo.Deduct(ctx, req.ProductID, req.Quantity); err != nil {
        return nil, fmt.Errorf("deduct inventory: %w", err)
    }

    s.auditLog.Record(ctx, "order_created", order.ID, req.UserID)
    return order, nil
}

Not a single line here is relaying. Delete this method and there's nowhere else in the system that achieves the same thing more concisely or clearly. That's the simplest test for Service value: if you delete this method, where does the business logic go? If the answer is "nowhere" or "somewhere worse," this method earns its file.

Conversely, if deleting the method means the business logic goes nowhere — because there was none to begin with — the method shouldn't have existed in the first place.

The Cost of Adding a Layer Isn't Just "One More File"

"Just one more file, one more relay call, no big deal" — this is the most common reason air Services keep existing. But this estimate misses the real cost.

The first cost is modification friction. Every new query interface requires changes in Controller, Service, and Repository — three files. Of those three changes, only two have substance — the Service change is just a relay. The friction is small per instance, but multiplied by interface count and time, it's a constant low-grade drag. Every time you add a field or change a query condition, you pass through the relay layer.

The larger cost is misleading newcomers. A new person sees the project's code organization — Controller → Service → Repository, strict three-layer architecture — and infers the project has enough business complexity to justify this structure. But when they dive in and find most Services are relays, they draw one of two conclusions. Either "this project isn't that complex but is over-architected." Or, worse: "so this is what a Service layer does; I'll write them like this too." They carry this understanding to the next project and keep manufacturing air.

There's another subtle problem: air Services occupy namespace. When you genuinely need a Service with real logic — like the CreateOrder above — it sits in the same class as twenty relay methods, or in the same directory as a pile of relay Services. Judgment-bearing code drowns in mechanical code. Visually indistinguishable, logically bearing completely different expectations. A "business orchestration" Service and a "data relay" Service share nothing but the name. Yet naming groups them as the same kind, making you think they're architecturally homogeneous.

Independent Reason for Change: A More Fundamental Test

A more fundamental criterion than "does it have business logic" for whether a layer should exist: does this layer have a reason to change independently of the layers above and below it?

This is the Single Responsibility Principle applied at the architecture level. Robert Martin's Common Closure Principle: classes in a package should be closed to the same kind of change. The converse: if two things change for different reasons, they shouldn't be in the same package. Push this up one level — the principle applies to architectural layers too. If a layer changes for reasons different from its adjacent layers, it has a reason to exist. If its changes always sync completely with another layer, it is that layer and shouldn't be separated.

Look at the three layers' change drivers:

  • Transport layer (Handler/Controller): changes come from how the outside world calls the system. REST to gRPC, JSON to Protobuf, splitting one endpoint into two — these are transport-layer changes triggered by API consumers' needs, not business rules.
  • Data access layer (Repository/DAO): changes come from data storage details. Switching databases, changing table structures, adding caching, read-write splitting — these are data-layer changes triggered by performance, cost, and storage strategy.
  • Business logic layer (Service): changes come from business rules themselves. "Only authenticated users can place orders," "same user can only use the new-customer promotion once per 24 hours," "certain product categories require additional approval" — these are business rule changes triggered by product requirements, compliance, and operations strategy.

Each layer has independent change drivers. When REST changes to gRPC, only the Handler changes. When PostgreSQL switches to MySQL, only the Repository changes. When business rules change, only the Service changes. This is layered architecture's true value: change is isolated within the layer it belongs to, without spreading.

Now look at "air Services" again: do they have an independent reason to change? No. A pure relay getUser method — what would make it change? Field additions? The Repository needs those too. Return format changes? The Handler needs those too. Its changes always sync completely with another layer. It's not independent logically and shouldn't be independent in code.

The reverse is equally dangerous: when business logic lives in the Handler or Repository, it's not independent, but it's in the wrong place. Business rules in a Handler change alongside transport-layer changes — a field rename might accidentally touch discount calculation logic. The same business rule scattered across multiple Handlers — "free shipping for first-time customers" — means when the rule changes, you have to modify every Handler, and each implementation might differ slightly.

When You Don't Need a Service Layer

The cleanest Service layer is "no Service layer" — when you genuinely don't need one.

When don't you? When the interface is a direct projection of a data source. A query endpoint, fetch one record by ID, return JSON. No cross-entity operations, no business rules, no side effects, no multi-source transactions. The Handler calls the Repository directly; nothing is lost. The only added code is the Handler's parameter parsing and JSON serialization — already within the Handler's responsibilities.

"But we might need permission checks before querying users later."

True, maybe later. But not now. When the need arrives, add it then — with known information: "we now need permission checks, so we need a Service method to orchestrate permission validation + user lookup." Not guessing under uncertainty: "we might need this someday, so let's build the Service layer now." Adding layers on known information is architecture. Adding layers on guesses is betting — betting that future requirements will prove the layer necessary. Winning the bet earns nothing; losing means silent sunk cost plus another tax on every future change.

This isn't anti-design. It's distinguishing two things: some changes you know will happen — an e-commerce system's order table will grow, order states will multiply from three to eight, business flows will become complex. Investing in architecture for such certainties is reasonable. But "the user query endpoint might need permission checks" — you don't know when, what permission model, whether it's simple token validation or complex role-based field-level access control. Pre-building a Service layer for this isn't "doing architecture" — it's pre-paying the cost of uncertainty.

Simple CRUD doesn't require architectural gymnastics. This isn't anti-architecture — it's anti-using-architecture-where-it-doesn't-apply.

The Service Layer's Place in the Broader Architectural Picture

Zooming out, the Service layer is just one of several ways to organize business logic.

Fowler in PEAA described three patterns for organizing business logic: Transaction Script, Domain Model, and Table Module. The Service layer fits closer to the Transaction Script end: one method per business use case, method internals are procedural, imperative orchestration. The Service is the container for use cases — each method tells you "what operation the system performs for this use case, in what order, under what constraints." This is the most intuitive way to organize business logic, and what most business systems actually use.

Moving toward Domain Model, business logic spreads across domain objects, and the Service layer's role shifts — from "use case container" to "domain object coordinator." It no longer holds business rules themselves (those are in domain objects) but is responsible for bringing the right domain objects together, triggering their collaboration, managing transaction boundaries.

Moving toward Table Module, business logic organizes in table-level modules — InvoiceModule handles all invoice operations, OrderModule handles orders. The Service layer's role further diminishes; business logic belongs to "modules" rather than "layers."

The point isn't which to choose — most systems aren't pure enough to use only one pattern. The point is: the Service layer isn't the only answer. Its existence and form should reflect your system's current information volume and business complexity shape. If your business logic is a clear set of use cases, each involving multi-table operations and business constraints — a Service layer (Transaction Script style) is the best fit. If your business rules are more about interactions between entities than use-case flows — you need Domain Model more, and the Service's form will differ. If your business is organized by data modules with little cross-module interaction — you may not need a separate Service layer; the modules themselves are the boundaries.

"Layered architecture" is treated as a universal practice, but it's actually a set of specific patterns chosen when facing particular types of complexity. Without understanding this context, layering becomes fill-in-the-blanks — inserting a Service between Controller and Repository regardless of whether it's needed.

Degradation Isn't a Single Bad Decision

A project's Service layer going from useful to useless is almost never the result of one decision. It's the accumulation of individually reasonable incremental decisions.

Initial version: three business use cases — create order, cancel order, refund — each involving multi-table operations and business constraints. The Service layer's three methods are substantive.

Then a request arrives: the admin panel needs a user list query. Following the project's established convention — Controller → Service → Repository, add code to all three layers. The Service's listUsers() is a pure relay. In isolation, this addition is completely reasonable — follow convention, maintain consistency, short development time. Nothing seems wrong.

Then another query request. Another relay Service method. And another. And another.

A year later, this Service class has twelve methods — four substantive business orchestration, eight pure relays. The Service directory has fifteen classes, nine of which are pure relays. Nobody says in code review "this relay method is redundant" — because in isolation, each relay method is harmless, and reviewers don't audit entire directories from a global perspective.

This is the insidious nature of this class of problem: locally optimal decisions can accumulate into globally suboptimal structures. Each individual decision is reasonable — "add a Service method following project convention" is never something a reviewer would flag. But twenty reasonable decisions, stacked together, can turn a once-judgment-bearing architecture into a mostly-air shape.

The only way to counter this degradation isn't code review judgment in the moment — it's having a clear, repeatable question at the design level. Not "does this conform to convention" but "does this layer have an independent reason to change in this context."

How to Judge

Audit your own Service files. Count methods meeting all of these criteria:

  1. Method body is a single repository call
  2. No calls to other services or repositories
  3. No conditional logic
  4. No side effects triggered

If over half qualify, the layer is air.

Not saying delete every one. Some might soon need logic. Some, while currently relays, sit on an entity "certain to grow more complex" — orders, payments, refunds — where you know the business process is still evolving. Judging air isn't about looking at code at a frozen moment; it's about whether you've asked "why" every time you added a layer. If you can't answer — not because you haven't thought it through, but because you never thought about it at all — that's not a judgment problem, that's a habit problem.

In the other direction: if your project has no Service layer and business logic is scattered across Handlers or Repositories, how do you judge whether to introduce one? Same logic in reverse: open the most complex Handlers and count the business logic inside. If three different Handlers have similar but slightly different inventory validation logic, two Handlers each implement coupon verification independently, four places compute the same discount formula with slightly different parameters — you don't need a Service layer by name, but you need something that can house these business rules with clear boundaries. It doesn't have to be called Service, but it must exist.

Both sides of the problem share the same root: whether business logic's home matches its complexity. Simple enough to not need its own home — giving it one is over-engineering. Complex enough to be duplicated across multiple places — not giving it one is scattered.

A Question to Take With You

About the Service layer, more important than "whether to have one" is being able to answer, every time you create a new Service file or decide not to:

Does this layer have a reason to change independently of the other layers?

If the next requirement changes both the Handler and the Repository, and the Service must change too — the Service is just relaying messages between two layers. If the next requirement changes only the Service — because it's a business rule change, transport and storage are unaffected — then this layer is right.

This question is independent of language, framework, and whether you use three-layer architecture. It tests something more fundamental: whether you make a conscious decision on every abstraction you add.

A system without a Service layer isn't necessarily bad. A system full of Services where nobody made a decision, definitely isn't good.