N-tier architecture is not about counting layers. It's not about following a rigid blueprint or checking boxes on a design document. It's about asking one question repeatedly: what responsibility belongs where, and why?
When you're building a system that needs to grow, change, and adapt without breaking under its own weight, the answer to that question becomes everything. The layers you build are walls—boundaries that isolate the kinds of changes that happen at different speeds, with different frequency, and with different consequences.
A layer should exist because it provides a boundary. HTTP changes. Business logic changes. Database schema changes. Domain rules change. Each of these changes at a different cadence. When you know what varies, you know where to build the wall.
The Core Idea: Boundaries, Not Just Layers
The exact number of layers is not important. Three layers, four layers, five—the number is less significant than the principle: separate your system based on responsibility.
Think of it this way: if the part of your system that talks to PostgreSQL changes, how much of the rest of your application needs to change? The answer should be: only the part that knows about PostgreSQL.
If the part of your system that handles HTTP requests changes, how much else is affected? The answer should be: only the part that knows about HTTP.
If your business rules change, those changes should ripple through your application in a way you can predict and control. You should know exactly which layers are impacted and which layers remain untouched.
This is what a good n-tier architecture gives you: predictable change, isolated concerns, and clearer boundaries.
The Four-Layer Foundation
Most systems benefit from separating into four core layers. Each layer has a distinct responsibility. Each layer knows about the layers below it but is unknown to the layers above it.
Things that change frequently
↓
┌───────────────────────────┐
│ HTTP / API │
├───────────────────────────┤
│ Application Use Cases │
├───────────────────────────┤
│ Domain / Business Rules │
├───────────────────────────┤
│ Infrastructure │
│ DB / Cache / External API │
└───────────────────────────┘
↑
Things implementation-specific
Layer 1: Presentation Layer (Request Receiver)
"Does this code deal with transport?" HTTP, JSON, gRPC, WebSocket.
The presentation layer is where the outside world knocks on your door. Its job is narrowly defined:
- Parse incoming requests (JSON, form data, etc.)
- Validate basic request format (is this JSON well-formed?)
- Call the application layer to handle the actual logic
- Convert the result back into an HTTP response
- Handle serialization, status codes, and error formatting
What it should NOT know:
- How a promotion is stored in the database
- How the video generation service works internally
- How subscription billing is validated
- The business rules that govern which operations are allowed
Example:
func CreatePromotion(w http.ResponseWriter, r *http.Request) {
var req CreatePromotionRequest
json.NewDecoder(r.Body).Decode(&req)
promotion, err := promotionService.Create(req)
// Convert result/error into HTTP response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(promotion)
}
The controller translates HTTP into application logic. That's it.
Layer 2: Application/Service Layer (Orchestration)
"Does this code coordinate a user or system action?" Create promotion. Publish promotion. Generate video. Cancel subscription.
This is where the use case orchestration happens. The service layer decides the sequence of steps required to fulfill a request.
The application layer:
- Brings together all the collaborators needed to complete a use case
- Controls the sequence: step 1, then step 2, then step 3
- Coordinates between repositories, domain objects, external services, and event publishers
- Does NOT care how those collaborators work internally
Example:
func (s *PromotionService) CreatePromotion(ctx context.Context, req CreatePromotionRequest) error {
// 1. Find restaurant
restaurant := s.restaurantRepo.Get(ctx, req.RestaurantID)
// 2. Check permissions
if !s.permissionService.CanCreate(restaurant, req.UserID) {
return ErrUnauthorized
}
// 3. Check subscription
s.subscriptionService.Validate(restaurant)
// 4. Create promotion (domain logic)
promotion := NewPromotion(restaurant, req.Title, req.Description)
// 5. Save promotion
s.promotionRepo.Save(ctx, promotion)
// 6. Trigger video generation
s.videoService.Generate(ctx, promotion)
return nil
}
Notice: the service doesn't know whether the repository uses PostgreSQL or MongoDB. It doesn't know whether the video service calls OpenAI or Runway. It just orchestrates the workflow.
Layer 3: Domain/Business Layer (Rules)
"Does this code define a business rule?" A promotion cannot be published before video generation completes. A restaurant can only create promotions if it has an active subscription.
The domain layer is where the rules of your business live. These rules should:
- Define what is valid and what is not
- Enforce invariants (things that must always be true)
- Be independent of HTTP, databases, caches, and external APIs
- Remain stable even as infrastructure changes
The domain layer should NOT know about:
- HTTP requests or responses
- Database queries or ORM frameworks
- Redis or caching layers
- AWS, Stripe, or any external services
Instead of directly updating data, the domain layer enforces a rule that dictates how the data should be updated. This keeps related business logic in one place.
Layer 4: Data Access & Infrastructure Layer (Implementation)
"Does this code talk to an external system?" Database, Redis, S3, video AI provider, Stripe, Kafka.
This is where the implementation details live. The infrastructure layer:
- Provides concrete implementations of interfaces defined by domain and application layers
- Handles persistence, caching, and external API calls
- Can be swapped out without changing business logic
Example:
The application layer depends on an interface:
type VideoGenerator interface {
Generate(ctx context.Context, prompt string) (Video, error)
}
The infrastructure layer provides the concrete implementation:
type EvatorVideoGenerator struct {
client *evator.Client
}
func (e *EvatorVideoGenerator) Generate(ctx context.Context, prompt string) (Video, error) {
// Call Evator API
// Handle errors
// Return video
}
Tomorrow, if you want to switch from Evator to Runway, you only change the infrastructure implementation. The application layer, domain layer, and presentation layer don't change at all.
Why This Matters: The Promotion Use Case
Let's walk through a real example to see how the layers work together.
Restaurant Owner wants to create a promotion with AI-generated video
Restaurant Owner
↓
POST /promotions
↓ (Presentation Layer)
Parse JSON, validate format
↓ (Application Layer)
1. Find restaurant
2. Check restaurant subscription
3. Check permissions
4. Create promotion (Domain Layer)
5. Save promotion (Infrastructure Layer)
6. Trigger video generation (Infrastructure Layer)
↓ (Application Layer)
Return promotion status
↓ (Presentation Layer)
Convert to HTTP response
At each step, the layer only knows what it needs to know:
- The controller doesn't know about subscriptions or video APIs
- The service doesn't know about HTTP or database queries
- The domain rules don't know about external services
- The infrastructure doesn't know about business logic
If the database changes: only infrastructure is affected.
If the video provider changes: only infrastructure is affected.
If the business rule changes (e.g., "subscription validation now requires an API call"): you modify the service orchestration, not the controller or domain.
The Dependency Rule
The most important rule in n-tier architecture: code dependencies flow inward.
- The presentation layer depends on the application layer
- The application layer depends on the domain and infrastructure layers
- The domain layer depends on nothing (it's the core)
- The infrastructure layer depends on abstractions defined by the domain
Bad: A controller directly calls a database query function.
Good: A controller calls a service, which uses a repository interface. The repository is implemented by the infrastructure layer.
This inversion of control is what allows you to:
- Test business logic without a database
- Swap implementations without rewriting logic
- Keep the core of your system stable while the edges change
Practical Takeaways: Building Layers That Earn Their Keep
1. Ask "What Varies?" Before You Layer
Don't add a layer because the architecture diagram shows four boxes. Add a layer because you have change pressure that justifies isolation.
Start with three layers: HTTP, logic, persistence. Add more if you find yourself writing the same rule-checking code in multiple places.
2. Depend on Interfaces, Not Implementations
The service layer should not import PostgresqlPromotionRepository. It should import PromotionRepository interface. Let the infrastructure layer wire the concrete implementation.
type PromotionRepository interface {
Save(ctx context.Context, promotion *Promotion) error
FindByID(ctx context.Context, id string) (*Promotion, error)
}
3. Test Each Layer in Isolation
- Domain logic: No database. No HTTP. No external APIs. Just unit tests.
- Application logic: Use mock repositories and services. Test the orchestration.
- Presentation logic: Call the service, verify the response format. No database needed.
When you can test a layer without spinning up other infrastructure, you know the boundary is real.
4. Make Layers Visible in Your Code
The directory structure should make the layers obvious:
src/
controllers/ (Presentation Layer)
services/ (Application Layer)
domain/ (Domain Layer & Interfaces)
repositories/ (Infrastructure Layer)
external/ (External APIs)
When someone new joins your team, they should be able to guess which layer a file belongs to just by looking at the path and imports.
5. Move Slowly Toward Abstraction
You don't need a repository interface on day one. You don't need five layers. Build what you need, test what you built, and refactor when you feel the pain of change.
The best architecture is the one that makes the next change easiest.
Common Mistakes to Avoid
Mistake 1: Leaky Abstractions
The presentation layer knows too much about the database. The application layer handles HTTP concerns. Layers become tangled, and changes affect everything.
Mistake 2: Over-engineering
Building elaborate abstractions before you need them adds complexity without reducing change cost. Start simple. Refactor when you feel the pressure.
Mistake 3: Circular Dependencies
The domain layer depends on the infrastructure layer. The infrastructure layer depends on the application layer. The application layer depends on the domain layer. Now nothing can be tested in isolation.
Mistake 4: God Objects
One massive service that knows about HTTP, business rules, database queries, and external APIs. This is the opposite of layering.
The Real Payoff
When your layers are clear:
- Change becomes predictable. You know where the change happens and what else it might affect.
- Teams can work independently. One team owns the API, another owns the domain, a third owns persistence.
- Testing is faster. You can test business logic without spinning up a database.
- Code is easier to read. A new engineer can trace a request from HTTP to storage without getting lost in implementation details.
- Refactoring is safer. You can swap out implementations knowing you didn't break the layers that depend on them.
Closing Thought
A good n-tier architecture is not about the number of layers. It's about asking one question repeatedly: what responsibility belongs here? And being willing to build the wall when the answer changes.
The layers that earn their keep are the ones that isolate change. Every other wall is just complexity.
Build your walls where the pressure is real. Leave them out where they're not. That's the art of architecture.
The best architecture is the one that makes the next change easiest. Keep asking the question, and let your layers earn their keep.
Top comments (0)