background-shape
June Retro, Clean Architecture in Practice
June 29, 2022 · 5 min read · by Muhammad Amal programming

TL;DR — Two services rebuilt with Clean Architecture in June: a Go billing service and a Laravel admin tool. Test speeds dropped from 2 min to under 5 sec. New-developer time-to-first-PR cut significantly. The verbosity is real; the payoff is real on services that outlive their first framework choice.

End of June. End of the Clean Architecture month. Two real refactors landed: our Go billing service was extracted from the monolith using the layered pattern, and our Laravel-based internal admin tool went from “Eloquent everywhere” to a domain-separated layout. This retro is the honest assessment of both.

What I shipped

Go billing service (from January’s extraction): restructured from a “controller → model” layout to Clean Architecture. Domain extracted, use cases separated, repository interfaces in use case package, in-memory fakes for tests.

Laravel admin tool: Eloquent-driven CRUD app, ~40 features, refactored to Domain + UseCase + Repository pattern. Eloquent confined to repository layer.

Both still in production. Both passing tests.

What worked

Test speed improvement. The biggest measurable win.

Go billing service:

  • Before: 1m 47s (tests hit real Postgres via Docker)
  • After: 4.2s (unit + use case tests in-memory; integration tests narrow)
  • ~25× faster

Laravel admin:

  • Before: 2m 15s (Laravel’s full bootstrap per test class + DB transactions)
  • After: 12s (domain tests don’t bootstrap Laravel)
  • ~11× faster

Faster tests = developers run them more often = bugs caught earlier. The compounding effect is real.

Onboarding clarity. Two new contractors started in June. Both ramped on the billing service in ~3 days vs the team’s average of ~7 days for similar prior services. They cited the clear separation between business logic and framework as the reason. The domain folder reads like documentation.

Replaceability — actually used. Mid-June we evaluated switching the Laravel admin’s DB layer from MySQL to Postgres for unrelated reasons. The Clean Architecture refactor meant the migration was a one-package change in app/Repository/Eloquent/. No domain or use case code touched. Two days of work instead of two weeks.

Use case files as documentation. “What does this service do?” → ls internal/usecase/ returns the answer. New POs and engineers grok it in minutes.

What didn’t work

Boilerplate fatigue on small features. A “list customers” use case in the admin tool is genuinely 60 lines of Clean Architecture when it could be 10 lines of “Customer::all()”. I shipped both ways and the Clean version is genuinely worse for trivial CRUD. Lesson: not every operation needs a use case. Some are fine as direct Eloquent calls in controllers, as long as the boundary stays clear.

DTO conversion duplication. Domain → DTO → JSON, three layers of mapping per response. For services with 30+ response types, this is real friction. Considered a code generator (using struct tags); didn’t ship one this month. Still on the list.

Eloquent friction in Laravel. The mapping between Eloquent models and domain entities was the highest-friction part of the Laravel refactor. ~200 lines of mapping code for the 12 entities. Working but ugly. The cost is real; the boundary value is real; the net is positive for our team size but it’s not free.

The “domain event” pattern got messy. Domain emits event; adapter publishes via Kafka or Laravel’s dispatcher. The boundary at the dispatch is awkward. Half the team wanted domain events to flow through Laravel’s event system directly; the other half wanted them strictly domain types. Compromise: domain emits typed events, adapter publishes. Not fully resolved.

What I’d cut

The strict interface-per-consumer pattern at small scale. For services with 5 use cases, having an interface for every repository in every consumer is overkill. One shared Repository interface across the use case package is fine until the package grows.

Multiple use case packages. Started with usecase/subscription/, usecase/invoice/, usecase/payment/. Reverted to a single usecase/ package with all files at the top. Subpackages added import friction without clarifying anything.

Generic value objects for everything. Money and EmailAddress value objects earned their keep. Username, PhoneNumber, ZipCode did not — they’re just strings with light validation. Pragmatism > purity.

What’s load-bearing now

Three patterns I’d never go back from:

  1. Domain entities as private-field, constructor-only types. The discipline of “you can’t construct an invalid X” is genuinely useful.
  2. Repository interface where consumed, not where implemented. Smaller interfaces, less coupling.
  3. Use case tests with in-memory fakes. The speed win alone justifies the structure.

Two things I treat as optional:

  1. Use case-specific DTOs (sometimes the domain entity is fine to return)
  2. Event publishing through ports (sometimes the framework’s dispatcher is enough)

Half-year wrap

Six months done. ~78 posts. The pattern is sustained. The audience continues to grow.

Some specific signals:

  • A reader implementing the n8n standup bot from May’s post sent a thank-you note
  • Three readers have asked about the Rust posts as part of evaluating Rust at their own shops
  • The blog has started showing up as the second or third result for some niche queries — happy surprise

What July looks like

July theme: Advanced Containerization — Docker Compose for local development and orchestration fundamentals. Pivot back to operational tooling. After January’s intro to Docker and June’s architecture deep-dive, July goes deep on Compose v2 specifically — the patterns that make polyglot local-dev work at scale.

Same shape: 13 articles, M/W/F, single theme. See you in July.