DEV Community

Artyom Kornilov
Artyom Kornilov

Posted on

Reducing Complexity: Replacing Entity-Based Services and Repositories with Purposeful Layers in Software Design

Introduction: The Prevalence of Entity-Based Patterns

Walk into any mid-sized software project today, and you’ll find the same architectural blueprint repeated ad nauseam: UserService, UserRepository, OrderService, OrderRepository. It’s become the default blueprint, a reflexive response to the question, “How do we structure this?” The pattern itself isn’t inherently flawed—layering is a fundamental principle of software design. But the mindless application of entity-based Services and Repositories is where the system starts to deform under its own weight.

Let’s break down the mechanism of this failure. In theory, a Service is supposed to encapsulate meaningful application behavior—interactions with payment gateways, email systems, or external APIs. A Repository, meanwhile, should abstract complex data access logic: multi-source queries, caching strategies, or evolving storage mechanisms. These layers are meant to isolate complexity, not create it. But in practice, they often function as empty shells:

  • Services degrade into mere delegates, forwarding calls without adding value. Example: a UserService that does nothing but call userRepository.save(user).
  • Repositories become thin wrappers around an ORM, adding no logic beyond what the ORM already provides. Example: a UserRepository with methods like findById(id) that directly map to JPA/Hibernate calls.
  • Interfaces are introduced without meaningful alternative implementations, turning them into ceremonial artifacts. Example: a UserRepository interface with a single Hibernate-backed implementation, never intended for mocking or polymorphism.

The causal chain here is straightforward: impact → internal process → observable effect. When these layers lack purpose, they introduce indirection without abstraction. Each additional layer adds cognitive load, file clutter, and potential failure points. The system doesn’t become more maintainable—it becomes harder to trace. Developers spend more time navigating boilerplate than solving actual problems. Over time, the codebase expands horizontally (more files, more classes) without deepening vertically (more meaningful logic per component).

Consider the risk mechanism: if a junior developer joins the project, they’ll likely replicate the existing pattern, compounding the issue. The architecture becomes self-perpetuating, not because it’s optimal, but because it’s familiar. This is where the pattern stops working: when it’s applied as a template, not a tool. The optimal solution? Design around intent, not structure.

For example, instead of a generic UserService, create a RegisterNewUser class that explicitly handles the use case. Instead of a UserRepository, use the ORM directly if there’s no complex data logic to abstract. The rule is simple: if the layer doesn’t isolate complexity, eliminate it. This approach reduces noise, aligns code with system behavior, and prevents the codebase from becoming a labyrinth of redundant abstractions.

The typical choice error here is over-abstraction: developers default to patterns because they fear under-engineering. But the real risk isn’t missing a layer—it’s adding one that doesn’t pay rent. The system breaks when layers accumulate without purpose, not when they’re omitted where unnecessary.

The Problem: Layers Without Purpose

Every day, I see projects where the layer-per-entity dogma reigns supreme: UserService, UserRepository, OrderService, OrderRepository, and so on. The issue isn’t layering itself—it’s how these layers are misapplied. Let’s dissect the mechanism of failure.

1. The Degradation of Services and Repositories

In theory, a Service should encapsulate meaningful application behavior—interactions with external systems, complex business logic, or significant use cases. A Repository should handle non-trivial data access: multiple data sources, caching strategies, or evolving persistence mechanisms.

In practice, they often deform into something far less useful:

  • Services as Delegates: A UserService that does nothing but call userRepository.save(user). No logic, no abstraction—just indirection that adds cognitive load without value.
  • Repositories as ORM Wrappers: A UserRepository that maps findById(id) directly to JPA/Hibernate. No complexity isolation, no additional logic—just file clutter.
  • Ceremonial Interfaces: Interfaces introduced without alternative implementations. They don’t decouple anything—they just expand the codebase horizontally without deepening it vertically.

2. The Causal Chain of Failure

Here’s how this pattern breaks software architecture:

  • Impact: Layers without purpose introduce indirection without abstraction.
  • Internal Process: Each unnecessary layer adds a failure point—a place where bugs can hide, where changes require coordination, and where developers must mentally navigate.
  • Observable Effect: The codebase expands horizontally (more files/classes) without deepening vertically (meaningful logic per component). Maintainability deteriorates, and scalability becomes a friction point.

3. The Risk Mechanism

Why does this happen? The risk forms through a combination of:

  • Familiarity Over Optimization: Junior developers replicate patterns they’ve seen, perpetuating suboptimal architecture because it feels safe.
  • Fear of Under-Engineering: Developers add layers out of fear of missing something, but over-abstraction is the typical error. Unnecessary layers heat up the codebase—they introduce complexity without isolating it.
  • Structural Inertia: Teams default to templates (e.g., layer-per-entity) without questioning their fit for the problem. The codebase becomes a mechanical system with too many gears—each one adding friction without contributing to motion.

4. Edge-Case Analysis: When Layers Justify Their Existence

Layers are not inherently bad. They justify their existence when they isolate complexity. For example:

  • A Service that orchestrates a payment gateway, sends emails, and updates multiple aggregates—this isolates cross-cutting concerns.
  • A Repository that handles sharded databases, caching, and audit logging—this isolates data access complexity.

But if the complexity isn’t there, the layer becomes a dead weight—a component that expands the system without contributing to its function.

5. Optimal Solution: Design Around Intent, Not Structure

The optimal solution is to eliminate layers that don’t isolate complexity. Here’s the rule:

  • If X (a layer doesn’t encapsulate meaningful logic or isolate complexity) → Use Y (remove it or replace it with something purpose-driven).

For example:

  • Replace generic UserService with RegisterNewUser or ProcessPayment—classes named after concrete actions, not entities.
  • Use the ORM directly if there’s no complex data logic to hide. Don’t introduce a Repository just because it’s in the blueprint.

6. Typical Choice Errors and Their Mechanism

Developers often make these mistakes:

  • Over-Engineering: Adding layers out of fear of under-engineering. Mechanism: Fear-driven design leads to unnecessary abstraction, which breaks maintainability.
  • Template Blindness: Applying structural templates without evaluating fit. Mechanism: Inertia leads to horizontal expansion without vertical depth, deforming the codebase into a bloated, hard-to-navigate mess.

7. Professional Judgment

Layers must justify their existence by isolating complexity. If they don’t, they’re noise. The codebase should reflect what the system does, not just how it’s layered. This isn’t about avoiding patterns—it’s about applying them intentionally. If a layer doesn’t serve a purpose, it’s a mechanical failure in your architecture—remove it before it heats up your system.

Scenarios and Alternatives: Rethinking Entity-Based Patterns

The default use of entity-based Services and Repositories often leads to architectural bloat. Below are six scenarios where these patterns fall short, paired with alternatives that simplify and clarify your codebase.

1. The Delegating Service: When a Layer Becomes a Middleman

Scenario: A UserService that does nothing but delegate calls to a UserRepository. For example:

  • UserService.saveUser(user) → userRepository.save(user)
  • UserService.getUserById(id) → userRepository.findById(id)

Mechanism of Failure: The Service layer adds indirection without abstraction. Each call introduces a failure point (e.g., method signature mismatch, null handling) and requires mental navigation between files. The codebase expands horizontally (more files) without deepening vertically (meaningful logic per component).

Alternative: Eliminate the Service layer if it doesn’t orchestrate cross-cutting concerns. Use the Repository directly or replace it with action-based classes (e.g., RegisterNewUser) that encapsulate intent.

Rule: If a Service only delegates calls, remove it. Layers must isolate complexity, not just pass it along.

2. The ORM Wrapper Repository: When Abstraction Adds Noise

Scenario: A UserRepository that thinly wraps an ORM. For example:

  • UserRepository.findById(id) → entityManager.find(User.class, id)
  • UserRepository.save(user) → entityManager.persist(user)

Mechanism of Failure: The Repository layer becomes ceremonial, adding files and indirection without hiding complexity. Developers must navigate between the Repository and the ORM, increasing cognitive load. If the ORM changes, every Repository method must be updated, breaking encapsulation.

Alternative: Use the ORM directly if there’s no complex data logic. If complexity arises (e.g., sharded databases, caching), justify the Repository by isolating that logic.

Rule: If a Repository is an ORM wrapper, remove it. Abstraction must hide complexity, not just rename it.

3. Ceremonial Interfaces: When Contracts Lack Purpose

Scenario: Introducing interfaces like IUserService or IUserRepository without alternative implementations. For example:

  • IUserService → UserService (no mock, no alternate implementation)

Mechanism of Failure: Interfaces without alternatives become ceremonial, expanding the codebase horizontally without enabling flexibility. They force developers to navigate between interface and implementation, adding friction without benefit.

Alternative: Only introduce interfaces when there’s a clear need for multiple implementations (e.g., mocking, alternate data sources). Otherwise, use concrete classes directly.

Rule: If an interface has no alternative implementation, remove it. Contracts must enable flexibility, not just add files.

4. Layer-Per-Entity: When Structure Overrides Intent

Scenario: Creating UserService, OrderService, etc., for every entity, regardless of use case. For example:

  • UserService handles user registration, login, and profile updates in separate methods.

Mechanism of Failure: The codebase becomes fragmented, with related logic scattered across entity-based classes. Developers must navigate multiple files to understand a single use case (e.g., registration involves UserService, EmailService, and UserRepository).

Alternative: Organize code around use cases, not entities. For example, replace UserService.register(user) with RegisterNewUser.execute(user), encapsulating all related logic in one place.

Rule: If logic is tied to a use case, not an entity, structure it accordingly. Code should reflect intent, not structural templates.

5. Over-Engineering for Future Complexity

Scenario: Adding layers “just in case” complexity arises later. For example:

  • Creating a UserRepository for a simple CRUD application with no plans for sharded databases or caching.

Mechanism of Failure: Fear-driven design leads to over-abstraction. Each layer adds cognitive load, file clutter, and potential failure points. The codebase becomes harder to maintain as developers must navigate unnecessary indirection.

Alternative: Design for current needs, not hypothetical futures. Add layers only when complexity justifies them. For example, start with direct ORM usage and introduce a Repository if data access becomes complex.

Rule: If complexity doesn’t exist, don’t engineer for it. Layers must justify their existence today, not tomorrow.

6. Junior Developer Replication: When Familiarity Overrides Intent

Scenario: Junior developers replicate entity-based patterns from previous projects without evaluating their fit. For example:

  • Creating UserService and UserRepository because “that’s how it’s done.”

Mechanism of Failure: Familiarity over optimization leads to suboptimal architecture. Patterns are applied without understanding their purpose, resulting in layers that lack intent. The codebase becomes bloated, and maintainability suffers as developers must navigate unnecessary complexity.

Alternative: Encourage critical evaluation of patterns. Educate developers on the purpose of layers and when to use them. Foster a culture of intentional design, not template replication.

Rule: If a pattern is applied by default, question its purpose. Layers must align with system behavior, not just familiarity.

Professional Judgment: Layers Must Justify Their Existence

The core issue with entity-based Services and Repositories is not their existence but their misapplication. Layers are valid when they isolate complexity—orchestrating cross-cutting concerns, handling non-trivial data access, or enabling flexibility. However, when they degrade into delegates, wrappers, or ceremonial interfaces, they become noise.

Optimal Solution: Design around intent, not structure. Replace entity-based classes with action-based classes when logic ties to use cases, not entities. Eliminate layers that don’t isolate complexity. Use ORMs directly if no complex data logic is needed.

Typical Errors:

  • Over-Engineering: Fear of under-engineering leads to bloated codebases.
  • Template Blindness: Defaulting to structural templates without evaluating fit.

Rule of Thumb: If a layer doesn’t isolate complexity, remove it. Code should reflect system functionality, not just structure. Apply patterns intentionally, not habitually.

Conclusion: Rethinking Architectural Defaults

The dogma of entity-based Services and Repositories has become a default in software projects, but it’s time to question its value. Every day, I see projects cluttered with layers like UserService, UserRepository, OrderService, and OrderRepository. The problem isn’t layering itself—it’s how these layers are misapplied, often lacking any real purpose beyond structural convention.

The Mechanism of Failure

Let’s break it down. In theory, a Service should encapsulate meaningful application behavior—orchestrating complex workflows, interacting with external systems, or implementing business logic. A Repository should abstract data access complexity, handling sharding, caching, or multi-source persistence. But in practice:

  • Services degrade into delegates. A UserService often does nothing but call userRepository.save(user), adding a layer of indirection without abstraction. This introduces a failure point—if the method signature changes, both layers must be updated. The codebase expands horizontally (more files) without deepening vertically (meaningful logic).
  • Repositories become ORM wrappers. A UserRepository might simply map findById(id) to JPA/Hibernate. If the ORM changes, every Repository method must be revised. The abstraction doesn’t hide complexity—it just renames it, adding cognitive load and file clutter.
  • Interfaces become ceremonial. Interfaces are introduced without alternative implementations, forcing developers to navigate unnecessary contracts. This adds friction without enabling flexibility, as seen in mocking or alternate data sources.

The Causal Chain

The impact is clear: indirection without abstraction. Each layer adds a failure point, requiring coordination and mental navigation. The codebase expands horizontally, but the logic remains shallow. Maintainability suffers as developers must trace through layers that don’t isolate complexity—they just pass it along.

The Risk Mechanism

Why does this happen? It’s a combination of familiarity over optimization and fear of under-engineering. Junior developers replicate patterns they’ve seen, perpetuating suboptimal architecture. Senior developers, fearing they might miss something, over-abstract, adding layers “just in case.” The result? A bloated codebase that’s harder to understand and maintain.

The Optimal Solution

Layers must justify their existence by isolating complexity. If they don’t, remove them. Here’s how:

  • Replace delegating Services with action-based classes. Instead of UserService.register(user), use RegisterNewUser.execute(user). This encapsulates logic in one place, reflecting intent, not structure. Rule: If a Service doesn’t orchestrate cross-cutting concerns, eliminate it.
  • Use ORMs directly unless complex data logic is needed. If there’s no sharding, caching, or multi-source persistence, a Repository is unnecessary. Rule: If a Repository doesn’t hide complexity, remove it.
  • Introduce interfaces only when multiple implementations are needed. Otherwise, they’re just noise. Rule: If there’s no alternative implementation, remove the interface.

Professional Judgment

The key is to design around intent, not structure. Code should reflect what the system does, not how it’s layered. For example, instead of organizing around User and Order, structure logic by use cases like ProcessPayment or SendConfirmationEmail. This aligns the codebase with system behavior, reducing noise and improving clarity.

Typical errors include over-engineering for hypothetical complexity and template blindness. Avoid these by critically evaluating each layer’s purpose. Rule of thumb: If a layer doesn’t isolate complexity, it’s not justified.

In conclusion, entity-based Services and Repositories aren’t inherently bad—they’re just often misapplied. By rethinking defaults and designing with intent, we can create codebases that are clearer, more maintainable, and aligned with real system behavior. It’s time to stop layering for the sake of layering and start building with purpose.

Top comments (0)