← Back to list

Before You Apply “Clean Architecture” — Read This

A critical look at over-layering, with a simpler alternative that worked for us.

Erwin Hermanto · 2026-06-19 02:01 · 0 claps · 6.4 min read paywalled
#golang #software-architecture #clean-code #backend-development #software-engineering
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🏛️ · Architecture

Before You Apply “Clean Architecture” — Read This

A critical look at over-layering, with a simpler alternative that worked for us.

A few years ago, every new service my team started looked the same. We’d open a terminal, run a generator, and watch it spit out folders: domain, usecase, repository, entity, delivery, infrastructure. Each one neatly separated, each one with its own interfaces, each one "decoupled" from the others.

It looked great in a diagram. On a whiteboard, those concentric circles from Uncle Bob’s book make total sense — dependencies pointing inward, business logic protected from the dirty outside world of databases and frameworks.

But somewhere between the diagram and the actual codebase, something went wrong.

The day I counted the files

I remember the moment it really hit me. A junior engineer on my squad asked me to help him add a single field — discount_percentage — to an order. Just one field.

To ship that change, he had to touch:

  • the Order entity struct
  • the OrderRepository interface
  • the Postgres implementation of that repository
  • the CreateOrderUseCase interface
  • the use case implementation
  • a DTO for the HTTP request
  • a DTO for the HTTP response
  • a mapper to convert between DTO and entity
  • another mapper to convert between entity and database model

Nine files. For one field. He spent more time figuring out where to put the field than actually writing the logic.

That’s when I started questioning whether we were doing Clean Architecture, or whether Clean Architecture was doing us.

Layers are not free

The promise of Clean Architecture (and Hexagonal Architecture, and Onion Architecture, and whatever flavor your team picked) is testability and independence from frameworks. Your business logic shouldn’t care if you’re using Postgres or MongoDB, REST or gRPC, Echo or Gin.

That’s a real benefit. I’m not arguing against it in principle.

But every layer you add has a cost:

  • Cognitive cost — new engineers need to learn “where things go” before they can be productive
  • Mapping cost — data gets converted between layers, and those mappers are pure boilerplate that can drift out of sync
  • Indirection cost — to understand what happens when an order is placed, you trace through five files instead of reading one function top to bottom
  • Change cost — a “simple” field touches every layer, every time

For a system with genuinely complex business rules — think a billing engine with dozens of pricing strategies, tax rules, and compliance constraints — these costs are worth paying. The complexity has to live somewhere, and Clean Architecture gives it a home.

But most of our internal services weren’t that. They were CRUD-ish services with a handful of business rules, talking to one database, exposed over one API. We were paying architecture tax on logic that didn’t need it.

What over-layering looks like in Go

Here’s a simplified version of what one of our “clean” handlers looked like for updating an order’s status:

// delivery/http/order_handler.go
func (h *OrderHandler) UpdateStatus(c echo.Context) error {
    var req UpdateOrderStatusRequest
    if err := c.Bind(&req); err != nil {
        return err
    }

    input := mapper.ToUpdateStatusInput(req)
    output, err := h.updateStatusUseCase.Execute(c.Request().Context(), input)
    if err != nil {
        return err
    }

    return c.JSON(http.StatusOK, mapper.ToUpdateStatusResponse(output))
}

// usecase/update_order_status.go
func (uc *updateOrderStatusUseCase) Execute(ctx context.Context, in UpdateStatusInput) (UpdateStatusOutput, error) {
    order, err := uc.orderRepo.FindByID(ctx, in.OrderID)
    if err != nil {
        return UpdateStatusOutput{}, err
    }

    if !order.CanTransitionTo(in.NewStatus) {
        return UpdateStatusOutput{}, ErrInvalidTransition
    }

    order.Status = in.NewStatus
    if err := uc.orderRepo.Update(ctx, order); err != nil {
        return UpdateStatusOutput{}, err
    }

    return mapper.ToUpdateStatusOutput(order), nil
}

// repository/order_repository.go (interface)
type OrderRepository interface {
    FindByID(ctx context.Context, id string) (entity.Order, error)
    Update(ctx context.Context, order entity.Order) error
}

// repository/postgres/order_repository.go (implementation)
func (r *postgresOrderRepository) FindByID(ctx context.Context, id string) (entity.Order, error) {
    var model OrderModel
    err := r.db.GetContext(ctx, &model, "SELECT * FROM orders WHERE id = $1", id)
    if err != nil {
        return entity.Order{}, err
    }
    return mapper.ToOrderEntity(model), nil
}

Four files, three mappers, two interfaces, one field changing state. The actual business rule — order.CanTransitionTo(in.NewStatus) — is one line. Everything else is ceremony.

What we changed

We didn’t throw the whole thing out. That would’ve been its own kind of overcorrection. Instead, we agreed on three principles for new services, and slowly migrated old ones when we touched them anyway.

1. Group by feature, not by technical layer.

Instead of domain/, usecase/, repository/ as top-level folders, we organized by feature: order/, payment/, merchant/. Inside each feature folder, related code lives together. You don't hunt across five directories to understand one feature — you open one folder.

2. Collapse use case and repository into a single service struct when there’s no real abstraction need.

If we only ever have one database implementation (which, honestly, is true for 90% of our services — nobody swaps Postgres for MongoDB mid-flight), the repository interface isn’t buying us anything except an extra file and an extra mapping step. We kept interfaces only where we actually had multiple implementations or needed them for testing with mocks.

3. Let the struct double as both entity and data model when the shapes match.

Mapping is only necessary when the database shape and the domain shape genuinely differ. When they don’t — which is often — we used the same struct with db and json tags side by side. One struct, two purposes, zero mapper functions.

Here’s the same handler after the rewrite:

// order/handler.go
func (h *Handler) UpdateStatus(c echo.Context) error {
    var req UpdateStatusRequest
    if err := c.Bind(&req); err != nil {
        return err
    }

    order, err := h.service.UpdateStatus(c.Request().Context(), req.OrderID, req.NewStatus)
    if err != nil {
        return err
    }

    return c.JSON(http.StatusOK, order)
}

// order/service.go
func (s *Service) UpdateStatus(ctx context.Context, orderID, newStatus string) (Order, error) {
    var order Order
    err := s.db.GetContext(ctx, &order, "SELECT * FROM orders WHERE id = $1", orderID)
    if err != nil {
        return Order{}, err
    }

    if !order.CanTransitionTo(newStatus) {
        return Order{}, ErrInvalidTransition
    }

    order.Status = newStatus
    _, err = s.db.ExecContext(ctx, "UPDATE orders SET status = $1 WHERE id = $2", newStatus, orderID)
    if err != nil {
        return Order{}, err
    }

    return order, nil
}

// order/model.go
type Order struct {
    ID     string `db:"id" json:"id"`
    Status string `db:"status" json:"status"`
    Amount int    `db:"amount" json:"amount"`
}

func (o Order) CanTransitionTo(newStatus string) bool {
    transitions := map[string][]string{
        "pending":   {"paid", "cancelled"},
        "paid":      {"shipped", "refunded"},
        "shipped":   {"delivered"},
        "delivered": {},
        "cancelled": {},
        "refunded":  {},
    }
    allowed, ok := transitions[o.Status]
    if !ok {
        return false
    }
    for _, s := range allowed {
        if s == newStatus {
            return true
        }
    }
    return false
}

Two files instead of four. No mappers. The business rule is right there, readable in one pass. If you need to add discount_percentage, you add it to one struct, and it flows through naturally.

The trade-off, honestly

This isn’t free either. We gave up some things:

  • Testing Service.UpdateStatus now means hitting a real database (or a test container), since there's no repository interface to mock. We solved this by giving Service a small dbExecutor interface — narrow enough to mock cheaply, but we don't pretend it's a "repository layer."
  • If we ever do need to support two databases, we’ll have to introduce that abstraction later. We decided that’s a good problem to have later, not a problem to pre-solve now.
  • Some engineers coming from Java/Spring backgrounds found the flatter structure unfamiliar at first. It took a couple of weeks of pairing to get everyone comfortable.

How it played out

We tracked time-to-merge for “small” tickets (single field additions, minor validation changes, small endpoint additions) across a few squads before and after the restructuring. Here’s roughly what we saw over about three months:

Median time-to-merge for small tickets (days)

Before restructuring:
Week 1-4   |████████████████████  2.1
Week 5-8   |███████████████████   2.0
Week 9-12  |████████████████████  2.2

After restructuring:
Week 1-4   |██████████████        1.4
Week 5-8   |███████████           1.1
Week 9-12  |██████████            1.0

The drop wasn’t instant — the first few weeks were spent migrating and getting used to the new layout. But by week 9, small tickets were taking less than half the time they used to.

We also looked at file-touch-count per PR for similar-sized changes:

Files changed per "small" PR (average)

Before:  ███████████████  7.4
After:   ████              3.6

Roughly half as many files. Not because we got lazier about separating concerns, but because we stopped separating concerns that didn’t need separating.

When Clean Architecture is still the right call

I want to be careful here, because I’m not saying “never use layers.” A few situations where the full layered approach still earns its cost:

  • You genuinely need to support multiple data sources or delivery mechanisms (e.g., the same business logic exposed over REST, gRPC, and a message consumer)
  • The business logic is complex enough that it deserves to be tested completely in isolation from infrastructure
  • You’re building a library or framework meant to be reused across many different projects with different storage backends
  • Regulatory or compliance requirements demand strict separation and auditability of business rules

If your service fits one of those, go for it. Just go for it because you need it, not because it’s the default template your generator produces.

What I’d tell my past self

Architecture should follow the shape of your problem, not the shape of a book chapter. Start simple. Let the pain — actual pain, not hypothetical future pain — tell you when to add a layer. It’s far easier to introduce an interface when you finally need a second implementation than to remove four layers of indirection that turned out to be guessing wrong about the future.

The nine files my junior engineer touched for one field weren’t a sign that we had good architecture. They were a sign that our architecture was bigger than our problem.


메타데이터
post_id
6ba2e83eafc3
slug
before-you-apply-clean-architecture-read-this-6ba2e83eafc3
url
https://medium.com/@erwindev/before-you-apply-clean-architecture-read-this-6ba2e83eafc3
canonical_url
https://medium.com/@erwindev/before-you-apply-clean-architecture-read-this-6ba2e83eafc3
author_url
https://medium.com/@erwindev
status
ok
fetched_at
2026-06-20 20:29:01