Layered is not Clean, even with interfaces everywhere
Over the years I have had this same conversation many times with different engineers. Different projects, different companies, but the same set of questions keeps coming back around Clean Architecture, layered architecture and the Dependency Inversion Principle.
Instead of writing a classic theory post, I decided to put all those questions together into one single back and forth, as if it was one long conversation. Some questions are even questions I have made myself in the past.
It touches a point that many people think they master and very few really do: the difference between depending on an abstraction and actually applying DIP.
If I define an abstract repository in the domain layer, data implements it and presentation consumes it, is that already hexagonal architecture?
Not exactly. That is the Repository pattern plus dependency inversion, which is one piece of hexagonal but does not define it. Hexagonal (Ports & Adapters) takes that same idea and applies it to every boundary of the system, not only persistence. Your abstract repository is a port, and your data layer is an adapter. In a project with domain, data and presentation, what you have looks more like Clean Architecture.
Then how do I tell these two models apart? One where data and presentation depend on domain, and another one where dependencies flow in a chain: data, domain, presentation.
The first one (domain in the center, nothing above it) is Clean, Onion or Hexagonal: the domain defines the contracts and depends on nobody. The second one is the traditional layered architecture: the domain knows data directly. The difference lives in one single point, the direction of the dependency between domain and data. Draw an arrow from the package that imports to the one being imported: in Clean it goes from data to domain, in layered it goes from domain to data.
That arrow criteria does not convince me. In a layered architecture you can also have inversion: the view builds a repository injecting services, and those services are implementations of abstractions, like FirebaseAuthenticationService extends AuthenticationService. There is abstraction and injection everywhere.
Yes, that pattern is real and it does have inversion. But look at where it lives. FirebaseAuthenticationService extends AuthenticationService inverts the dependency between two classes that both live in data: the Firebase implementation depends on an abstraction next to it. That is local DIP inside the low level world. Useful for mocking, and not much more.
The boundary that decides whether the architecture is layered or Clean is a different one, the one between domain and data. And at that boundary none of those abstractions helps you: your use cases still reach into data to get to AuthenticationService. Dependency injection is not dependency inversion. You can inject abstractions in any architecture. What decides the architecture is one single thing: who owns the abstraction at the boundary.
Wait. So creating an AuthRepository and a FirebaseAuthRepositoryImpl extends AuthRepository, is that not dependency inversion by itself?
That is polymorphism, which you always get with an abstract class and an implementation. Inversion is a fact about the direction of imports between packages, and that direction depends on where the abstract class lives.
Case A, with inversion: AuthRepository in domain, the implementation in data. Data imports domain, domain does not import data.
Case B, without inversion: both classes in data and domain importing them. You can inject, you can mock in tests, but you did not invert anything.
The one line test: look at the imports. If any file inside domain has an import from data, there is no inversion.
Still, the classic argument of “you can swap Firebase without touching domain” is also true in layered if the abstraction is stable.
True, and that is a good point. The mechanical swap works in both but the real difference lives in two other things.
One, compilation independence: in Clean you could extract domain into its own package with zero infrastructure dependencies. In layered, domain does not compile without the data package, even if it only depends on an interface.
Two, who shapes the contract. In Clean the abstract class lives in domain, so its shape is dictated by the needs of the business. In layered the abstract class lives next to Firebase, and in practice the detail leaks: methods returning provider types, semantics copying the SDK. There the swap stops being free.
I lived this exactly. Years ago I worked on an auth system built on Cognito that had to run on both web and mobile. We tried to define an AuthenticationClient abstraction hoping it would stay stable, but as we moved forward with the implementation we kept having to change it: the specificities of the Cognito SDK, challenge flows, tokens, session types, pushed themselves back into the interface. The abstraction that was supposed to be neutral was actually shaped by one SDK, and when the other platform arrived it had to force the fit. The textbook symptom: you add the second implementation and suddenly you have to change the interface to make it fit. If the contract had belonged to the domain, the authentication abstraction would have been a more flexible piece of our system since no domain entity would have to interact with it.
Let’s go to the principle itself. Many people think that creating AuthService plus FirebaseAuthService extends AuthService, and making everyone depend on the first one, is already DIP. Where is the mistake?
That is half of DIP, the easy half. The principle has two parts:
A) High level modules do not depend on low level modules, both depend on abstractions. This is the part everybody follows.
B) Abstractions do not depend on details, details depend on abstractions. This is the part that is usually missing, and it is the one about who owns the interface.
If AuthService lives next to Firebase, your abstraction belongs to the low level world. Everyone depends on an interface, yes, but the interface itself belongs to the detail. You only put an interface in the middle. The real inversion happens when the abstract class lives with the consumer, and Firebase goes down to fulfill a contract that is not its own.
Define high level and low level, the names are confusing.
High level: the business logic, the rules, the what. What would survive a full technology change. Low level: the technical details, the how. Firebase, HTTP, databases, SDKs.
The name confuses people because high level does not mean more important technically, it means further from the machine and closer to the business problem. LoginUseCase is high level. FirebaseAuthService is low level.
Show it to me in code. An app with Firebase in data and a User entity in domain.
Clean version, DIP respected:
// domain/user.dart
class User {
final String id;
final String email;
User(this.id, this.email);
}
// domain/user_repository.dart
import 'user.dart'; // only imports things from domain
abstract class UserRepository {
Future<User> getCurrentUser();
}
// data/user_repository_impl.dart
import '../domain/user.dart'; // data looks at domain
import '../domain/user_repository.dart'; // data looks at domain
class UserRepositoryImpl implements UserRepository {
final FirebaseAuth _firebase;
final UserApi _api;
UserRepositoryImpl(this._firebase, this._api);
@override
Future<User> getCurrentUser() async {
final fbUser = _firebase.currentUser;
final profile = await _api.fetch(fbUser.uid);
return User(fbUser.uid, profile.email); // translates to YOUR User
}
}
Imports: domain has zero imports from data. Data imports domain. Direction inverted.
Layered version, DIP broken:
// data/authentication_service.dart
abstract class AuthenticationService {
String? get currentUserId;
}
// data/firebase_authentication_service.dart
import 'authentication_service.dart';
class FirebaseAuthenticationService implements AuthenticationService {
final FirebaseAuth _firebase;
FirebaseAuthenticationService(this._firebase);
@override
String? get currentUserId => _firebase.currentUser?.uid;
}
// data/api_service.dart
import 'user_dto.dart';
abstract class ApiService {
Future<UserDto> fetchProfile(String uid);
}
// data/dio_api_service.dart
class DioApiService implements ApiService {
final Dio _dio;
DioApiService(this._dio);
@override
Future<UserDto> fetchProfile(String uid) => /* ... */;
}
// domain/user_repository.dart
import 'user.dart';
import '../data/authentication_service.dart'; // domain looks at data: here it breaks
import '../data/api_service.dart'; // and again
class UserRepository {
final AuthenticationService _auth;
final ApiService _api;
UserRepository(this._auth, this._api);
Future<User> getCurrentUser() async {
final uid = _auth.currentUserId!;
final dto = await _api.fetchProfile(uid);
return User(uid, dto.email);
}
}
Notice what happens here. Inside data, both services have their own abstraction and their Firebase / Dio implementation. That is real DIP, but a local one, between two neighbours in the low level world. Useful for mocking those services in tests.
At the boundary that matters, the one between domain and data, DIP is broken. UserRepository lives in domain but is a concrete class that imports AuthenticationService and ApiService from data. There is no abstraction at the boundary that domain owns. The direction of the dependency at that boundary is still domain to data, no matter how many interfaces you added inside data.
Last objection. “Inverted” compared to what. If I do not compare with a layered architecture, how do I know that data -> domain is the inverted direction and not just a direction?
This is the piece almost nobody has clear. Inverted is not relative to another architecture. It is relative to the flow of control inside the same system.
At runtime, the high level always calls the low level: your use case ends up executing Firebase code. That is the flow of control, and it goes from high to low, always.
The other arrow is the flow of dependency: who imports who in the code. In the naive case, both arrows go the same way, from high to low. What DIP does is flip the dependency arrow so it goes from low to high, against the flow of control. That is the only thing “inverted” means: the code dependency points the opposite way of the control. It is an absolute property of the system, you do not need to compare it with anything.
And from here comes another consequence: DIP is a principle at module level, not architecture level. You can apply it once, by hand, in an app without layers. Clean and Hexagonal are just the result of applying it consistently on the boundaries that matter.
The summary
- Layered can have local DIP inside data (Firebase depends on an abstract
Auth). But the boundary that matters, the one between domain and data, is not inverted: the high level imports the low level. Inversion where it does not count, and missing where it does. - Clean inverts exactly that boundary: data depends on the abstractions that domain owns. DIP respected where it matters.
- DIP is not “depend on an interface”. It is “the interface belongs to the one who uses it, not to the one who implements it”.
- The one line test: look for an
importfrom data inside domain. If it exists, there is no inversion, no matter how many interfaces you have.
If that Cognito system from the example had been born with the contract in the domain, web and mobile would have been two adapters translating each SDK into our own Session, and the interface would never have been touched. That is the difference between putting interfaces and applying the principle.