Chapter 8: Clean Architecture in Go
Clean Architecture in Go
Section titled “Clean Architecture in Go”Clean Architecture is an architectural pattern introduced by Robert C. Martin (Uncle Bob) that emphasizes separation of concerns through well-defined layers with explicit dependency rules. The core idea is to structure your application so that business logic remains independent of external concerns like databases, web frameworks, or UI technologies.
In Go applications, Clean Architecture provides a blueprint for organizing code in a way that makes it easier to test, maintain, and evolve over time. Unlike simpler architectural patterns like MVC (Model-View-Controller), Clean Architecture enforces strict boundaries between layers, ensuring that your core business logic never depends on infrastructure details.
This chapter explores how to apply Clean Architecture principles to Go applications. You’ll learn about the dependency rule, how to structure your code into layers, and practical patterns for implementing each layer. While this architecture adds initial complexity, it pays dividends in medium-to-large applications where testability, maintainability, and flexibility matter.
The key benefit: your business logic becomes a pure, testable core that can outlive any framework or database choice. Want to switch from PostgreSQL to MongoDB? Your domain layer doesn’t change. Need to add a gRPC API alongside your HTTP API? Your use cases remain untouched. This independence is the promise of Clean Architecture.
Understanding Clean Architecture
Section titled “Understanding Clean Architecture”What is Clean Architecture?
Section titled “What is Clean Architecture?”Clean Architecture is a software design philosophy that prioritizes three core principles: independence from frameworks, testability, and separation of concerns. The architecture divides an application into concentric circles, where each circle represents a different level of abstraction. The innermost circles contain business logic and policies, while outer circles contain mechanisms and implementation details.
This pattern emerged from decades of experience with various architectural approaches including Hexagonal Architecture, Onion Architecture, and Screaming Architecture. Clean Architecture unifies these concepts under a single set of rules that apply regardless of your programming language or specific technology stack.
The Dependency Rule
Section titled “The Dependency Rule”The Dependency Rule is the golden rule of Clean Architecture: dependencies point inward. Inner layers know absolutely nothing about outer layers. This means:
- The domain layer has zero knowledge of databases, HTTP, or frameworks
- The application layer knows about the domain but nothing about HTTP or specific databases
- Outer layers can depend on anything inside them, but never the reverse
This inversion of traditional dependencies is what makes Clean Architecture powerful. In most codebases, business logic gets tangled with framework code and database queries. Clean Architecture prevents this by making infrastructure details depend on abstractions defined by your business logic.
┌──────────────────────────────────────┐│ Frameworks & Drivers │ <- HTTP, gRPC, DB│ ┌────────────────────────────────┐ ││ │ Interface Adapters │ │ <- Controllers, Repos│ │ ┌──────────────────────────┐ │ ││ │ │ Application Layer │ │ │ <- Use Cases│ │ │ ┌────────────────────┐ │ │ ││ │ │ │ Domain Layer │ │ │ │ <- Entities, Business Rules│ │ │ └────────────────────┘ │ │ ││ │ └──────────────────────────┘ │ ││ └────────────────────────────────┘ │└──────────────────────────────────────┘Why does this matter? When dependencies point inward, you can test your business logic without spinning up databases or web servers. You can change your database from PostgreSQL to DynamoDB without touching business logic. You can add new delivery mechanisms (REST, gRPC, CLI) by only adding code, never modifying existing business logic.
The dependency rule is enforced through Go interfaces. Outer layers implement interfaces defined by inner layers. This is Dependency Inversion in action: high-level policy (business logic) defines what it needs, and low-level details (infrastructure) provide it.
When to Use Clean Architecture
Section titled “When to Use Clean Architecture”Clean Architecture isn’t free. It adds layers, interfaces, and indirection. For a simple CRUD API or a weekend prototype, this overhead isn’t worth it. You’ll spend more time organizing code than delivering features.
Clean Architecture shines when:
- Your application will live for years and needs to adapt to changing requirements
- Multiple teams work on the codebase and need clear boundaries
- Testing is critical - you need fast unit tests without external dependencies
- You might swap infrastructure - changing databases, message queues, or frameworks is a real possibility
- Business logic is complex - your domain has intricate rules that deserve isolation from technical details
Signs you’ve outgrown simple architecture:
- Business logic scattered across HTTP handlers
- Database queries mixed with validation rules
- Testing requires spinning up the entire application stack
- Changing frameworks means rewriting significant portions of code
- Different teams are stepping on each other’s toes
Start simple. When these pain points emerge, incrementally introduce Clean Architecture layers. Don’t over-engineer small problems, but don’t underestimate the complexity of growing applications.
The Domain Layer
Section titled “The Domain Layer”What is the Domain Layer?
Section titled “What is the Domain Layer?”The domain layer is the heart of your application. It contains your business entities, value objects, domain events, and business rules. This layer represents the core concepts of your problem space, independent of how data is stored or how users interact with it.
In the domain layer, you define what an “Order” is, what a “User” is, and the rules that govern them. You don’t define how to save an Order to PostgreSQL or how to render a User in JSON. Those are infrastructure concerns that live in outer layers.
The domain layer has zero external dependencies. No imports of database/sql, no HTTP packages, no framework code. In Go, this means your domain package imports only Go standard library packages like time, errors, and fmt - and even those sparingly. The goal is pure business logic that could theoretically compile and run in any Go environment.
Think of the domain layer as the answer to “What is this application about?” It’s where domain experts (business stakeholders) can recognize their vocabulary and rules. A shipping company’s domain layer would include concepts like Package, Shipment, Route, and rules like “packages over 50kg require special handling.”
Why Domain Independence Matters
Section titled “Why Domain Independence Matters”Domain independence isn’t an academic exercise. It delivers real, practical benefits:
Testability Without Infrastructure: When your business logic doesn’t depend on databases or frameworks, you can test it with pure Go unit tests. No Docker containers, no test databases, no mocking libraries. Create a User struct, call its methods, assert the results. Tests run in milliseconds instead of seconds.
Framework Agnosticism: Web frameworks rise and fall in popularity. Database technologies evolve. By keeping domain logic separate, you’re protected from these changes. When you decide to migrate from Echo to Chi, or from GORM to sqlx, your domain code remains untouched. The migration effort is confined to outer layers.
Clarity and Focus: When business logic lives in HTTP handlers or mixed with database code, it’s hard to see what the application actually does. Extract it to a pure domain layer, and suddenly the core behavior is crystal clear. This clarity helps new team members onboard faster and helps everyone reason about complex business rules.
Parallel Development: With clear boundaries, different team members can work on different layers simultaneously. One developer implements HTTP handlers while another writes business logic. As long as they agree on the interfaces between layers, their work doesn’t collide.
Reusability Across Interfaces: Your CreateUserUseCase shouldn’t care if it’s called from an HTTP handler, a gRPC service, or a CLI command. By keeping the domain pure, you can expose the same business logic through multiple delivery mechanisms without duplication.
Real-world example: Imagine you build an e-commerce platform with business logic scattered across HTTP handlers. Two years later, you need to add a GraphQL API. With clean domain separation, you write new GraphQL resolvers that call existing use cases. Without it, you’re either duplicating logic or painfully extracting it mid-project.
Common Domain Patterns in Go
Section titled “Common Domain Patterns in Go”Validation Methods: Attach validation logic directly to domain entities as methods. This keeps business rules co-located with the data they validate. In the example above, Validate() ensures a User meets business requirements before being persisted.
Value Objects: Use custom types (like UserID string) instead of primitives. This provides type safety and prevents mixing up different IDs. You can’t accidentally pass an OrderID where a UserID is expected. Value objects also give you a place to attach domain-specific behavior.
Domain Events: Events signal that something significant happened in your domain. A UserCreatedEvent might trigger welcome emails or analytics tracking. Events are particularly useful when one domain action should trigger side effects in other parts of the system.
Factory Functions: Use constructor functions like NewUser() to enforce invariants. Instead of letting callers create invalid Users, a factory can generate IDs, set timestamps, and run validation automatically. This prevents invalid objects from ever existing.
Business Logic in Methods, Not Functions: Prefer user.Activate() over ActivateUser(user). Methods keep related logic bundled with data, making your domain more object-oriented and easier to discover.
No Database Concerns: Domain entities should never know about database columns, JSON tags, or ORMs. Keep annotations in separate DTOs at the infrastructure layer. This keeps the domain pure and prevents database details from leaking into business logic.
The domain layer is where Go’s simplicity shines. No magic, no code generation, just structs and methods that express business concepts clearly. Focus on making this layer read like the business requirements document, and the rest of the architecture falls into place.
The Repository Pattern
Section titled “The Repository Pattern”Understanding Repositories
Section titled “Understanding Repositories”The Repository pattern is how Clean Architecture handles data persistence while maintaining the dependency rule. Instead of letting the domain depend on databases, we define repository interfaces in the domain layer and implement them in the infrastructure layer.
A repository is an abstraction over data storage that provides a collection-like interface for accessing domain entities. From the domain’s perspective, it looks like you’re working with an in-memory collection. Whether that data actually comes from PostgreSQL, MongoDB, or a REST API is an implementation detail hidden behind the interface.
This inversion is crucial: the domain defines what storage operations it needs (FindByID, Save, Delete), and outer layers provide implementations. The domain depends on its own interface, not on any concrete storage technology.
Repositories sit at the boundary between the domain and infrastructure. They’re defined as interfaces in the domain but live as concrete implementations in the infrastructure layer. This is the Dependency Inversion Principle in action: both layers depend on the abstraction.
Repository Design in Go
Section titled “Repository Design in Go”Context Usage: Every repository method should accept a context.Context as its first parameter. This enables request timeouts, cancellation, and passing request-scoped values like transaction contexts or trace IDs. It’s idiomatic Go and essential for production systems.
Error Handling: Be explicit about errors. Return errors.New("not found") or define a custom ErrNotFound that callers can check with errors.Is. Don’t return nil, nil for “not found” - that’s ambiguous. Clear error semantics make callers’ lives easier.
Pointer vs Value Returns: For entities, return pointers (*User) not values (User). This is conventional in Go and allows returning nil to indicate absence. It also avoids copying large structs. For arrays/slices, return slices directly.
Repository Granularity: Create one repository per aggregate root. Don’t make a generic Repository[T] that handles all entity types. Specific repositories (UserRepository, OrderRepository) can have domain-specific query methods like FindActiveOrders() that would be awkward in a generic interface.
Transaction Handling: Repositories shouldn’t manage transactions directly. Instead, accept a transaction context through the Go context.Context or a more explicit transaction object. Let the use case layer coordinate transactions across multiple repository calls.
Query Methods: Define query methods that match domain needs, not database capabilities. Instead of FindByField(field, value), prefer FindByEmail(email) or FindActiveUsers(). These names reflect business concepts, not implementation details.
In Go, repository interfaces are delightfully simple - just method signatures with clear names. No base classes, no framework magic. Define what your domain needs, then implement it however makes sense for your storage technology.
Repository Testing
Section titled “Repository Testing”The repository interface is what makes testing use cases trivial. In tests, you can provide an in-memory implementation like the one above. No database required, no Docker containers, no test data cleanup. Just instantiate an InMemoryUserRepo, populate it with test data, and pass it to your use case.
This same pattern works in production. Your production code uses a PostgresUserRepo that executes SQL, while your tests use InMemoryUserRepo or mock implementations. Both satisfy the UserRepository interface, so your use cases work identically with either.
When you need more sophisticated test scenarios - testing error conditions, race conditions, or slow queries - you can create specialized test implementations. A FailingUserRepo that always returns errors tests error handling paths. A SlowUserRepo that adds delays tests timeout behavior. The interface gives you complete control over test conditions without complex mocking frameworks.
Use Cases (Application Layer)
Section titled “Use Cases (Application Layer)”What are Use Cases?
Section titled “What are Use Cases?”Use cases, also called interactors or application services, orchestrate business workflows. They coordinate between domain entities and repositories to accomplish specific user goals. A use case answers the question “What can users do?” rather than “What data do we have?”
While domain entities contain business rules that are always true (“an Order must have a positive amount”), use cases contain application-specific business rules (“when creating an Order, check if the user has sufficient credit”). Domain rules are about what’s valid; use case rules are about what operations are allowed.
Use cases know about the domain layer below them and repository interfaces, but nothing about HTTP, databases, or delivery mechanisms above them. A CreateUserUseCase takes input, coordinates domain objects, calls repositories, and returns output. It doesn’t know if that input came from a REST API, GraphQL mutation, or CLI command.
Each use case should have a single, well-defined responsibility. Don’t create a massive UserService with twenty methods. Create CreateUserUseCase, UpdateUserEmailUseCase, and DeactivateUserUseCase as separate types. This keeps code focused and makes it easier to understand, test, and evolve.
Use Case Design Principles
Section titled “Use Case Design Principles”Input/Output DTOs: Use cases accept input as Data Transfer Objects (DTOs) and return output DTOs. These are different from domain entities. An input might be CreateUserInput{Email, Name} while the domain entity is User{ID, Email, Name, CreatedAt}. Separating these prevents coupling use case signatures to domain structure.
Single Responsibility: Each use case handles one user intention. Creating a user is one use case. Sending a welcome email is a separate use case. Composing them is fine, but don’t mix concerns within a single use case. This keeps code understandable and testable.
Error Handling Strategy: Use cases should return domain errors, not HTTP status codes. Return errors.New("email already exists"), not a 409 status code. The controller layer translates domain errors to HTTP responses. This separation means the same use case can work across different delivery mechanisms.
Transaction Coordination: When a use case needs to coordinate multiple repository operations atomically, it should manage the transaction. Use Go’s context.Context to pass transaction state, or accept a transaction object explicitly. The use case decides what needs to be atomic; infrastructure provides the mechanism.
Composition Over Inheritance: Need to reuse logic across use cases? Extract it to a shared function or a separate use case, then call it. Don’t create base classes or try to share behavior through inheritance. Go doesn’t have inheritance, and that’s a feature.
Interface Adapters (Controllers)
Section titled “Interface Adapters (Controllers)”Understanding Controllers
Section titled “Understanding Controllers”Controllers, also called handlers or adapters, sit at the boundary between external delivery mechanisms and your application. Their job is translation: convert incoming requests into use case inputs, execute the use case, and convert outputs into responses.
A controller’s sole responsibility is adapting protocols. An HTTP controller parses JSON requests, extracts data, calls use cases, and formats JSON responses. A gRPC controller does the same for protobuf. A CLI controller parses command-line arguments. Same use case, different adapters.
Controllers should be thin. All business logic belongs in use cases or the domain. Controllers handle only protocol concerns: parsing, validation of input format (not business validation), serialization, and mapping status codes. If you find yourself writing if-else logic in a controller, that logic probably belongs in a use case.
The beauty of this separation: you can have multiple controllers calling the same use case. Your CreateUserUseCase works identically whether called from HTTPUserController, GRPCUserController, or CLIUserController. Write the business logic once, expose it many ways.
Controller Responsibilities in Go
Section titled “Controller Responsibilities in Go”Request Validation: Controllers validate input format, not business rules. Check that required fields are present, that JSON is well-formed, that integers are integers. Don’t check if an email is already taken - that’s a business rule for the use case to enforce.
DTO Conversion: Transform protocol-specific request objects into use case inputs. An HTTPCreateUserRequest with JSON tags becomes a CreateUserInput with no JSON tags. This prevents use cases from depending on HTTP or any specific protocol.
Error Translation: Use cases return domain errors. Controllers translate these to appropriate protocol responses. ErrNotFound becomes HTTP 404. ErrValidation becomes HTTP 400. ErrDuplicate becomes HTTP 409. This translation logic lives in controllers, not use cases.
Status Code Selection: HTTP status codes are protocol concerns. Controllers decide whether a successful operation returns 200, 201, or 204. Use cases just indicate success or failure through errors.
Keeping Controllers Thin: A controller should be boring. Parse request, call use case, format response. If it’s doing more, you’re mixing concerns. Complex logic in controllers is a red flag that business logic has leaked out of use cases.
Dependency Injection: Controllers receive use cases through their constructors. Never instantiate use cases inside controller methods. This makes controllers testable and allows reusing use cases across different controllers.
Bringing It All Together
Section titled “Bringing It All Together”How the Layers Interact
Section titled “How the Layers Interact”Now that you’ve seen each layer individually, let’s trace a complete request through all layers to see how they work together. Understanding this flow is crucial for implementing Clean Architecture correctly.
Step 1: HTTP Request Arrives
An HTTP POST request hits your server at /users with JSON body {"email": "alice@example.com", "name": "Alice"}. Your web framework routes this to a controller.
Step 2: Controller Parses and Validates Format
The UserController.HandleCreateUser method receives the raw request body. It unmarshals the JSON into a CreateUserRequest struct. If JSON parsing fails, it immediately returns 400 Bad Request. No use case is called - this is a format error, not a business error.
Step 3: Controller Converts to Use Case Input
The controller creates a CreateUserInput{Email: "alice@example.com", Name: "Alice"} - a simple Go struct with no JSON tags, no HTTP concerns. This input is protocol-agnostic. The same input could come from a gRPC call or CLI command.
Step 4: Controller Calls Use Case
The controller calls createUserUseCase.Execute(ctx, input). This call crosses the boundary from the Interface Adapters layer into the Application layer. The controller doesn’t know or care what happens inside the use case.
Step 5: Use Case Checks Business Rules
Inside the use case, business logic executes. First, it checks if a user with this email already exists by calling userRepo.FindByEmail(ctx, email). This call crosses into the domain boundary - it’s calling a domain-defined interface.
Step 6: Repository Query Executes
The repository interface is implemented by PostgresUserRepo (in production) or InMemoryUserRepo (in tests). This implementation lives in the Infrastructure layer. It executes SELECT * FROM users WHERE email = $1 against PostgreSQL. The use case has no idea this is happening - it only knows it called an interface method.
Step 7: Repository Returns Result
The repository returns nil (user not found) or *User (user exists). If the user exists, the use case immediately returns an error: errors.New("email already registered"). This is a business rule error, not a database error.
Step 8: Use Case Creates Domain Entity
If the email is unique, the use case creates a new User domain entity. It generates an ID, sets the email and name, and might call user.Validate() to enforce domain rules. If validation fails, the use case returns an error without calling the repository.
Step 9: Use Case Persists Entity
The use case calls userRepo.Save(ctx, user) to persist the new user. Again, this crosses into infrastructure. The PostgresUserRepo executes an INSERT statement. If the database is down or the query fails, the repository returns an error.
Step 10: Use Case Returns Output
On success, the use case constructs CreateUserOutput{ID, Email, Name} and returns it. The output is a DTO, not the domain entity - this prevents coupling the API response structure to domain structure.
Step 11: Controller Translates Output
Back in the controller, it receives either an output or an error. For errors, it checks the error type and maps to appropriate HTTP status codes. "email already registered" becomes 409 Conflict. Generic errors become 500 Internal Server Error.
Step 12: Controller Formats Response
For success, the controller converts CreateUserOutput to CreateUserResponse (which has JSON tags), marshals to JSON, and returns it with status 201 Created.
Key Observations:
- Dependencies point inward at every step. Controllers depend on use cases. Use cases depend on repository interfaces. Infrastructure implements those interfaces.
- Each layer is isolated. Change the controller from JSON to protobuf? Use case unchanged. Swap PostgreSQL for MongoDB? Use case unchanged. Refactor business logic? Controllers unchanged.
- Testing is simple at each layer. Test controllers with mock use cases. Test use cases with in-memory repositories. Test domain entities with no mocks at all.
- Data transforms at boundaries. HTTP request → Input DTO → Domain entity → Output DTO → HTTP response. Each layer works with types appropriate to its concerns.
This is Clean Architecture in action. It looks like more code than a simple handler with embedded SQL, and it is. But that code is organized, testable, and maintainable. As your application grows from dozens to thousands of lines, this investment pays exponential dividends.
Project Structure and Organization
Section titled “Project Structure and Organization”myapp/├── cmd/│ └── api/│ └── main.go # Wire everything together├── internal/│ ├── domain/ # Entities, value objects│ │ ├── user.go│ │ └── order.go│ ├── application/ # Use cases│ │ ├── user/│ │ │ ├── create.go│ │ │ └── find.go│ │ └── order/│ ├── infrastructure/ # External implementations│ │ ├── postgres/│ │ │ └── user_repo.go│ │ └── redis/│ │ └── cache.go│ └── interfaces/ # Controllers, presenters│ ├── http/│ │ └── user_handler.go│ └── grpc/└── pkg/ # Shared librariesUnderstanding the Structure
Section titled “Understanding the Structure”cmd/: Entry points for your application. Each binary gets its own directory. cmd/api/main.go wires everything together - instantiates repositories, creates use cases, configures controllers, starts the HTTP server. This is the only place where all layers meet.
internal/domain/: Your business entities and rules. This package imports almost nothing. It’s the core of your application, completely independent of infrastructure.
internal/application/: Use cases organized by feature or entity. Each subdirectory (user/, order/) contains use cases related to that concept. Use cases import from domain/ and depend on repository interfaces defined in domain/.
internal/infrastructure/: Concrete implementations of repository interfaces. postgres/ contains PostgreSQL implementations, redis/ contains caching implementations. These packages import database drivers and depend on both domain/ (for interfaces) and external libraries.
internal/interfaces/: Controllers and adapters for different delivery mechanisms. http/ contains HTTP handlers, grpc/ contains gRPC services. They depend on application use cases but know nothing about infrastructure.
pkg/: Shared utilities that could theoretically be extracted into external libraries. Keep this small. If code is business-specific, it belongs in internal/.
This structure enforces the dependency rule through Go’s package system. domain/ can’t import from infrastructure/ because that would create a cycle. The compiler prevents you from violating the architecture.
Gradual Adoption Strategy
Section titled “Gradual Adoption Strategy”You don’t need to start with full Clean Architecture. Introducing all these layers to a simple CRUD app is overkill. Instead, adopt incrementally as complexity grows.
Stage 1: Separate Handlers from Business Logic Start by extracting business logic from HTTP handlers into dedicated functions or service structs. This alone makes code more testable. You’re not doing full Clean Architecture yet, but you’re separating concerns.
Stage 2: Introduce Repository Interfaces When database queries start appearing in multiple places, define repository interfaces. Implement them once, use them everywhere. This makes testing easier and centralizes data access logic.
Stage 3: Create Use Cases As business logic grows complex, extract it into use case structs. Each use case coordinates a specific workflow. This makes business rules explicit and reusable across delivery mechanisms.
Stage 4: Full Clean Architecture When you need multiple delivery mechanisms (REST + gRPC), or when swapping infrastructure is a real concern, complete the layer separation. Define clear boundaries between domain, application, infrastructure, and interfaces.
The key: let complexity drive architecture. Don’t over-engineer. But when pain points emerge (untestable code, scattered business logic, framework lock-in), incrementally introduce Clean Architecture patterns to address them.
Best Practices & Common Pitfalls
Section titled “Best Practices & Common Pitfalls”Do: Keep the Domain Pure
No imports of database/sql, HTTP packages, or frameworks in domain code. The moment you add a JSON tag to a domain entity, you’ve coupled domain to HTTP. Use separate DTOs.
Don’t: Make Everything an Interface
Only define interfaces where you need abstraction - typically at layer boundaries. Don’t create IUserService and UserServiceImpl. That’s Java thinking. Use interfaces for repositories and occasionally for use cases, but not everywhere.
Do: Use Constructor Functions
Create NewCreateUserUseCase(repo UserRepository) *CreateUserUseCase functions that enforce required dependencies. This makes dependencies explicit and prevents nil pointer errors.
Don’t: Pass Database Connections to Use Cases
Use cases depend on repository interfaces, not database connections. If a use case accepts *sql.DB, you’ve broken the dependency rule. Pass repositories that hide the database.
Do: Keep Use Cases Focused
One use case, one responsibility. Creating a user is different from updating a user. Don’t create a UserService with twenty methods. Create twenty focused use cases.
Don’t: Obsess Over Performance “But all these layers must be slow!” They’re not. Go interfaces are fast. What’s actually slow is database queries, network calls, and poor algorithms. Clean Architecture doesn’t cause performance problems - it makes them easier to find and fix.
Do: Test at the Right Level Test domain logic with pure unit tests (no infrastructure). Test use cases with in-memory repositories. Test controllers with mock use cases. Test integration at the edges. Don’t mock everything everywhere.
Don’t: Force Clean Architecture on Simple Problems If your application is truly just CRUD with minimal business logic, Clean Architecture might be overkill. It’s okay to have simple handlers with direct database access. Apply architecture when complexity justifies it.
Performance Considerations
Section titled “Performance Considerations”Clean Architecture doesn’t inherently hurt performance. The layers add minimal overhead - a few function calls and interface dispatches. In Go, interfaces compiled away when possible, and even when they’re not, the cost is nanoseconds.
What does hurt performance:
- N+1 queries: Nothing to do with Clean Architecture. Eager load related entities.
- Lack of caching: Also unrelated. Add caching in the repository layer.
- Inefficient algorithms: Clean Architecture makes these easier to identify and fix, not harder.
What helps performance:
- Easy profiling: With clear layers, you can profile use cases, repositories, and controllers separately. Bottlenecks become obvious.
- Targeted optimization: When a repository is slow, you can optimize or replace it without touching use cases.
- Testable performance: Write performance tests for critical use cases using in-memory repositories. Actual timing variations come from infrastructure, not business logic.
Clean Architecture trades a tiny upfront cost (function call overhead) for massive long-term gains (maintainability, testability, flexibility). In every real application, the bottleneck is never the architecture - it’s the database, external APIs, or algorithmic complexity.
Key Takeaways
Section titled “Key Takeaways”- Dependencies point inward - outer layers depend on inner
- Domain is pure - no external dependencies
- Interfaces at boundaries - define contracts, not implementations
- Use cases orchestrate - coordinate between domain and infrastructure
- Controllers translate - convert between external and internal formats
Exercise
Section titled “Exercise”Refactor a Monolithic Handler
Given a handler that mixes HTTP, business logic, and data access, refactor it into proper layers with a repository interface and use case.
Next up: Chapter 9: Dependency Injection