When Clean Architecture Is Worth the Ceremony
Clean Architecture adds overhead. More projects, more abstractions, more ceremony. Here's when that trade-off pays for itself -- and when it doesn't.
By Igor Riera
Clean Architecture adds overhead. More projects, more abstractions, more ceremony for every feature. Every new endpoint means touching multiple layers. Every dependency needs an interface and a registration. Of course, this means the first month of development is slower than it would be with a simpler structure.
So why do we use it?
The Cerberus financial modeling platform started as a simpler structure. As we added valuation model types, external data providers, and authentication, the boundaries between layers started mattering. Early on it wasn’t because we designed for it upfront, but because the system told us it needed them as complexity grew. The realization came as our data source integration requirements started to truly shape up. Here’s what that evolution looked like in practice, and the rule we’ve arrived at for when the ceremony earns its keep.
What the structure looks like
The Cerberus platform today has 7 C# projects:
- WebApi – HTTP hosting, middleware, Swagger configuration
- Api – Controllers, request/response DTOs, input validation
- Application – CQRS handlers (commands and queries via MediatR), business orchestration
- Core – Domain entities, interfaces, value objects, enums
- Data – Entity Framework Core, repository implementations, migrations
- Identity – Authentication and authorization (JWT, role management)
- Shared – Cross-cutting concerns (logging abstractions, common utilities)
The dependency rule flows inward: WebApi → Api → Application → Core. Data and Identity implement interfaces defined in Core. No project references anything above it in the dependency chain.
CQRS with MediatR
Every operation is either a command (changes state) or a query (reads state), and MediatR dispatches both.
A query to fetch a valuation model’s results:
public record GetValuationQuery(Guid ModelId) : IRequest<ValuationResult>;
public class GetValuationHandler : IRequestHandler<GetValuationQuery, ValuationResult>
{
private readonly IValuationRepository _repo;
private readonly IMarketDataProvider _marketData;
public GetValuationHandler(
IValuationRepository repo,
IMarketDataProvider marketData)
{
_repo = repo;
_marketData = marketData;
}
public async Task<ValuationResult> Handle(
GetValuationQuery request,
CancellationToken ct)
{
var model = await _repo.GetByIdAsync(request.ModelId, ct);
var prices = await _marketData.GetHistoricalPrices(
model.Ticker, model.DateRange, ct);
return model.Calculate(prices);
}
}
The handler depends on interfaces, it doesn’t know whether IMarketDataProvider calls AlphaVantage, Polygon.io, or a cached local store. It doesn’t know whether IValuationRepository uses EF Core, Dapper, or an in-memory collection. It asks for data and runs business logic and that’s it. It shouldn’t need to care, that’s the key feature.
The data provider problem
The Cerberus platform queries multiple external data sources:
- AlphaVantage for real-time and historical equity prices
- Polygon.io for tick-level market data
- SEC EDGAR for company filings and financial statements
- FRED (Federal Reserve) for macroeconomic indicators
Each provider has its own API format, rate limits, authentication method, and failure characteristics. AlphaVantage returns CSV by default. Polygon returns JSON with pagination. EDGAR returns XBRL filings that need parsing. FRED has its own observation format.
Behind the interface, each gets its own adapter:
public interface IMarketDataProvider
{
Task<IReadOnlyList<PricePoint>> GetHistoricalPrices(
string ticker, DateRange range, CancellationToken ct);
bool Supports(DataRequestType requestType);
}
public class AlphaVantageProvider : IMarketDataProvider { /* ... */ }
public class PolygonProvider : IMarketDataProvider { /* ... */ }
When we needed to add Polygon as a data source, we wrote one class implementing IMarketDataProvider and registered it. The Application layer – all the valuation logic, all the query handlers – didn’t change one single line.
When AlphaVantage changed their rate limiting policy, we updated one adapter; the rest of the system didn’t know or care.
That’s the payoff from a proper clean implementation. Not on day one – on day 200, when the third external dependency changes its API and you swap an implementation instead of rewriting a feature. The boundaries earn their keep over time, very seldom upfront.
When it’s not worth it
Clean Architecture isn’t always the right call. We’ve seen projects where it adds friction without delivering value:
Single data source, straightforward CRUD. If your app reads and writes to one database and the business logic is “validate input, save to database, return result,” seven projects is ceremony for its own sake. A well-organized single project with clear folder structure serves you better.
Short shelf life. If the tool has a defined lifespan – a migration script, an internal dashboard that’ll be replaced in six months, a proof of concept – investing in architectural boundaries is over-engineering; build it simply, ship it, and move on.
Small team, stable requirements. If one or two developers own the entire codebase and the requirements aren’t changing, the abstraction layers solve a coordination problem that doesn’t exist. The overhead of maintaining interfaces and registrations outweighs the flexibility they provide.
No external integrations. If your system doesn’t need to talk to anything you don’t control, one of Clean Architecture’s biggest advantages – insulating your business logic from external dependencies – doesn’t really apply. You control both sides of every boundary, so the boundaries matter less.
The rule we follow
If the application will outlive its first version and will integrate with systems you don’t control, invest in the boundaries early.
“Outlive its first version” means it’ll grow. New features, new data sources, new requirements you can’t predict today. The boundaries give you places to make changes without cascading rewrites.
“Integrate with systems you don’t control” means external APIs, third-party services, data feeds from other organizations. These change on someone else’s schedule. Interfaces let you absorb those changes at the adapter level instead of deep in your business logic.
If neither condition applies, keep it simple. A well-structured single project is better than a poorly understood multi-project architecture. The ceremony is only worth it when the boundaries are real – and you’ll know they’re real when the system starts telling you where it needs them.