Architecture
Are you really using Clean Architecture?
How disguised transaction scripts may be sabotaging your domain.
Cristiano Rego Barros
8 min read
Originally published on Medium
Have you ever heard of transaction script and domain model? I bet you have, most likely in connection with topics like DDD or Clean Architecture. But do you know how they differ? What is the impact of using each pattern? In this short article, I will walk through a few examples and, in particular, a migration from a transaction script to a domain model.
For newcomers to clean architecture, it is worth understanding the implications of each model. Many developers, when they first engage with these topics, may believe they are building a clean architecture because they put business rules in use cases, have classes called entities and use repositories to talk to the database — when, in fact, clean architecture goes a bit further than that.
According to Fowler, a transaction script treats each piece of client–server logic as a single procedure: each transaction has its own transactional script, and common sub-activities can be broken down into smaller procedures. From this description, we can picture an application whose exposed behaviors each have their own procedure, written in a procedural style.
Then the question comes up: “but in my application the business rules are encapsulated in use cases, so I don’t have procedural code in my project.” Are you sure?
The starting point: a vehicle fleet
To make things easier, let’s imagine a vehicle fleet management system. To get started, the client asks to be able to register new vehicles and update them. The developer begins by creating a class that represents the car entity.
class Car {
public id: string;
public engine: string;
public color: string;
public ignitionOn: boolean;
public documentStatus: CarDocumentStatus;
public chassi: string;
constructor(
id: string,
engine: string,
color: string,
ignitionOn: boolean,
documentStatus: CarDocumentStatus,
chassi: string,
) {
this.id = id;
this.engine = engine;
this.color = color;
this.ignitionOn = ignitionOn;
this.documentStatus = documentStatus;
this.chassi = chassi;
}
}
Now that the developer has their “entity”, they move on to the use case for registering new vehicles.
interface ICarRepository {
save(car: Car): Promise<void>;
findById(carId: string): Promise<Car>;
}
type CreateNewCarInput = {
engine: string;
color: string;
ignitionOn: boolean;
documentStatus: CarDocumentStatus;
chassi: string;
};
class CreateNewCarUseCase {
constructor(private readonly repository: ICarRepository) {}
async execute(input: CreateNewCarInput): Promise<{ carId: string }> {
if (!input.engine || !input.chassi || !input.color || !input.chassi) {
throw new ValidationError('A required attribute was not provided');
}
if (!/^\d[A-HJ-NP-PR-Z]{4}\d{2}[A-HJ-NP-PR-Z]{4}\d{6}$/.test(input.chassi)) {
throw new ValidationError('Invalid chassis number!');
}
const car = new Car(
crypto.randomUUID(),
input.engine,
input.color,
false,
CarDocumentStatus.VALID,
input.chassi,
);
await this.repository.save(car);
return { carId: car.id };
}
}
Right after that, they create the update use case, which ends up looking something like this:
class UpdateCarUseCase {
constructor(private readonly repository: ICarRepository) {}
async execute(carId: string, updates: Partial<CreateNewCarInput>): Promise<void> {
const car = await this.repository.findById(carId);
if (updates.engine) car.engine = updates.engine;
if (updates.color) car.color = updates.color;
if (updates.chassi) car.chassi = updates.chassi;
if (updates.chassi && !/^\d[A-HJ-NP-PR-Z]{4}\d{2}[A-HJ-NP-PR-Z]{4}\d{6}$/.test(updates.chassi)) {
throw new ValidationError('Invalid chassis number!');
}
await this.repository.save(car);
}
}
After writing and reviewing this code, the developer might say: “now I follow clean architecture and have a rich domain.” Are they right? Or does this code need some fixes?
The anemic entity
Let’s first look at the Car entity. Looking at it, would you say this class has encapsulation? Let’s raise a few questions to think about:
- Since every attribute is public, what would stop its users from changing the identifier after the entity has been created?
- What would stop the ignition attribute from being changed without validating other rules?
- As for the chassis, both use cases validate its format. But is that rule inherent to creating and updating the car, or inherent to the chassis itself?
- What would stop another implementation from creating or changing a car without validating the chassis format, since the entity treats it as a
string? - Could the default initialization values (such as document status and ignition) differ, given that the creation use case is the one defining them?
When you create entity classes that contain only field mappings, a constructor and getters and setters, you are not building a rich domain or following the Domain Model pattern. On the contrary: you are building an anti-pattern known as the anemic entity.
As Fowler himself points out, anemic entities can be confusing at first because they use domain names and relate to domain objects. But on closer inspection, they turn out to be just a bag of getters and setters — nothing more, nothing less than procedural design, completely at odds with object orientation and breaking one of its pillars: encapsulation.
Migrating to a domain model
It is time for our entity to take on its responsibilities. First, let’s create a class that encapsulates every rule related to the chassis. Any implementation that needs a chassis attribute can use it.
export class Chassi {
private readonly value: string;
constructor(inputValue: string) {
if (!/^\d[A-HJ-NP-PR-Z]{4}\d{2}[A-HJ-NP-PR-Z]{4}\d{6}$/.test(inputValue)) {
throw new DomainError('Invalid chassis number!');
}
this.value = inputValue;
}
public getChassi(): string {
return this.value;
}
}
Now we can rewrite the car class, protecting the data that should not change and adding a factory method that sets the default values, so there are no inconsistencies.
class Car {
public readonly id: string;
private readonly engine: string;
private readonly color: string;
private ignitionOn: boolean;
private documentStatus: CarDocumentStatus;
private readonly chassi: Chassi;
constructor(
id: string,
engine: string,
color: string,
ignitionOn: boolean,
documentStatus: CarDocumentStatus,
chassi: Chassi,
) {
this.id = id;
this.engine = engine;
this.color = color;
this.ignitionOn = ignitionOn;
this.documentStatus = documentStatus;
this.chassi = chassi;
}
public static create(engine: string, color: string, chassi: string): Car {
const carChassi = new Chassi(chassi);
return new Car(crypto.randomUUID(), engine, color, false, CarDocumentStatus.VALID, carChassi);
}
public getEngine(): string { return this.engine; }
public getColor(): string { return this.color; }
public getIgnitionOn(): boolean { return this.ignitionOn; }
public getDocumentStatus(): CarDocumentStatus { return this.documentStatus; }
public getChassi(): string { return this.chassi.getChassi(); }
}
Notice that most attributes are private: changing their values requires going through the class’s internal rules. The identifier, although public, is read-only — once set when the entity is built, it can never be changed.
In addition, the entity has a factory method that uses the Chassi object, which prevents invalid chassis values. The use case can then be simplified to something like this:
class CreateNewCarUseCase {
constructor(private readonly repository: ICarRepository) {}
public async execute(input: CreateNewCarInput): Promise<{ id: string }> {
const entity = Car.create(input.engine, input.color, input.chassi);
await this.repository.save(entity);
return { id: entity.id };
}
}
And how could the update use case be structured? First, we need to create update methods on the entity. Since only the color and the engine can change, only those methods are created — and those two attributes are no longer readonly:
class Car {
public readonly id: string;
private engine: string;
private color: string;
private ignitionOn: boolean;
private documentStatus: CarDocumentStatus;
private readonly chassi: Chassi;
// constructor, create() and getters as in the previous example
public alterColor(newColor: string): void {
if (!newColor) throw new DomainError('The new car color cannot be empty!');
this.color = newColor;
}
public alterEngine(newEngine: string): void {
if (!newEngine) throw new DomainError('The new car engine cannot be empty!');
this.engine = newEngine;
}
}
The use case can then look something like this:
class UpdateCarUseCase {
constructor(private readonly repository: ICarRepository) {}
async execute(carId: string, updates: Partial<CreateNewCarInput>): Promise<void> {
const car = await this.repository.findById(carId);
if (updates.engine) car.alterEngine(updates.engine);
if (updates.color) car.alterColor(updates.color);
await this.repository.save(car);
}
}
This way, only changeable attributes can be modified, respecting the application’s rules without creating data inconsistencies. Every object that uses the car entity must respect its rules and obey its constraints, reducing the chance that the core rule diverges depending on the implementation. Rules for objects that aren’t necessarily entities, such as the chassis, are centralized too. On top of that, code duplication drops and use cases get leaner, because they are no longer responsible for implementing business rules — only for orchestrating them.
Conclusion
I can’t be a hypocrite and claim that these changes alone make an application one that implements clean architecture. Still, we can agree that with a design change like this, your application will be on its way to having a rich domain, protecting its invariants, reducing inconsistencies and having a maintainable, evolvable codebase.
It is worth stressing that every application has its own context, and we shouldn’t always follow paths like these. Patterns are not laws, just recommendations that depend heavily on context. A clear example is CQRS (Command-Query Responsibility Segregation), which advocates different models for mutations (commands) and reads (queries), since read transactions don’t need complexity in their objects. For read models, I recommend simpler models, so you can focus on what truly needs complexity.