What is SOLID?
SOLID is an acronym for five design principles that help you write code that is easy to maintain, easy to test, and easy to extend. These principles were popularized by Robert C. Martin (Uncle Bob), and every senior developer should know them by heart.
Let me explain each one with everyday analogies first, then show real code from our fleet system.
| Letter | Principle | Simple Analogy |
|---|---|---|
| S | Single Responsibility | A chef cooks. A waiter serves. Don't make the chef serve tables. |
| O | Open/Closed | A power strip accepts new plugs without being rewired. |
| L | Liskov Substitution | If you order a "vehicle", any vehicle (car, truck, van) should work. |
| I | Interface Segregation | A TV remote shouldn't have buttons for the DVD player. |
| D | Dependency Inversion | A lamp depends on "electricity", not on "the coal power plant on 5th street". |
S — Single Responsibility Principle
"A class should have only one reason to change."
The Problem: God Controller
Here's a real anti-pattern I've seen in production codebases:
// ❌ BAD — This controller does EVERYTHING
@Controller('api/deliveries')
export class DeliveryController {
@Post()
async createDelivery(@Body() dto: CreateDeliveryDto) {
// Validate business rules
const driver = await this.driverRepo.findOne(dto.driverId);
if (driver.status !== 'active') throw new BadRequestException('Driver not active');
if (driver.isLicenseExpired) throw new BadRequestException('License expired');
const vehicle = await this.vehicleRepo.findOne(dto.vehicleId);
if (vehicle.status === 'maintenance') throw new BadRequestException('Vehicle in maintenance');
if (vehicle.fuelLevel < 20) throw new BadRequestException('Fuel too low');
// Create the delivery
const delivery = await this.deliveryRepo.save({ ...dto, status: 'scheduled' });
// Update vehicle status
await this.vehicleRepo.update(dto.vehicleId, { status: 'active' });
// Send notifications
await this.emailService.send(driver.email, 'New delivery assigned');
await this.smsService.send(driver.phone, 'Check your app for new delivery');
// Log for audit
await this.auditRepo.save({ action: 'delivery_created', userId: req.user.id });
// Calculate estimated arrival
const distance = await this.mapService.getDistance(dto.origin, dto.destination);
const eta = distance / 60; // assume 60 km/h average
return { delivery, estimatedArrivalMinutes: eta };
}
}
This controller has 6 reasons to change: validation rules, delivery creation, vehicle updates, notifications, audit logging, and ETA calculation. Any change to any of these concerns requires modifying this single file.
The Refactored Version
// ✅ GOOD — Each class has ONE responsibility
// 1. Validation logic
@Injectable()
export class DeliveryValidator {
constructor(
private readonly driverRepo: DriverRepository,
private readonly vehicleRepo: VehicleRepository,
) {}
async validate(dto: CreateDeliveryDto): Promise<void> {
const driver = await this.driverRepo.findOneOrFail(dto.driverId);
if (driver.status !== 'active') throw new BadRequestException('Driver not active');
if (driver.isLicenseExpired) throw new BadRequestException('License expired');
const vehicle = await this.vehicleRepo.findOneOrFail(dto.vehicleId);
if (vehicle.status === 'maintenance') throw new BadRequestException('Vehicle in maintenance');
if (vehicle.fuelLevel < 20) throw new BadRequestException('Fuel too low');
}
}
// 2. Business logic
@Injectable()
export class DeliveryService {
constructor(
private readonly validator: DeliveryValidator,
private readonly deliveryRepo: DeliveryRepository,
private readonly eventEmitter: EventEmitter2,
) {}
async create(dto: CreateDeliveryDto, userId: string): Promise<Delivery> {
await this.validator.validate(dto);
const delivery = await this.deliveryRepo.save({ ...dto, status: 'scheduled' });
// Emit event — let other services react
this.eventEmitter.emit('delivery.created', { delivery, userId });
return delivery;
}
}
// 3. Controller — thin, only handles HTTP concerns
@Controller('api/deliveries')
export class DeliveryController {
constructor(private readonly deliveryService: DeliveryService) {}
@Post()
async create(@Body() dto: CreateDeliveryDto, @Req() req: Request) {
return this.deliveryService.create(dto, req.user.id);
}
}
Now each class has one reason to change. The validator changes when business rules change. The service changes when the creation flow changes. The controller only changes when the HTTP interface changes.
O — Open/Closed Principle
"Open for extension, closed for modification."
Real Example: Notification Channels
Our fleet system needs to send notifications via multiple channels: Email, SMS, WhatsApp, and Slack. Here's the wrong way and the right way:
// ❌ BAD — Must modify this class every time we add a channel
class NotificationSender
{
public function send(string $channel, string $to, string $message): void
{
if ($channel === 'email') {
Mail::to($to)->send(new GenericMail($message));
} elseif ($channel === 'sms') {
$this->twilioClient->messages->create($to, ['body' => $message]);
} elseif ($channel === 'whatsapp') {
// Added later — had to modify the class
$this->whatsappApi->send($to, $message);
} elseif ($channel === 'slack') {
// Added even later — modified the class AGAIN
Http::post($this->slackWebhook, ['text' => $message]);
}
}
}
// ✅ GOOD — Open for extension, closed for modification
// The contract
interface NotificationChannel
{
public function send(string $to, string $message): void;
public function supports(string $channel): bool;
}
// Each channel is its own class
class EmailChannel implements NotificationChannel
{
public function send(string $to, string $message): void
{
Mail::to($to)->send(new GenericMail($message));
}
public function supports(string $channel): bool
{
return $channel === 'email';
}
}
class SmsChannel implements NotificationChannel
{
public function send(string $to, string $message): void
{
$this->twilioClient->messages->create($to, ['body' => $message]);
}
public function supports(string $channel): bool
{
return $channel === 'sms';
}
}
// The sender — NEVER needs to be modified when adding new channels
class NotificationSender
{
/** @param NotificationChannel[] $channels */
public function __construct(private readonly array $channels) {}
public function send(string $channel, string $to, string $message): void
{
foreach ($this->channels as $handler) {
if ($handler->supports($channel)) {
$handler->send($to, $message);
return;
}
}
throw new \InvalidArgumentException("Unsupported channel: {$channel}");
}
}
// Adding WhatsApp? Just create a new class. NO existing code modified.
class WhatsAppChannel implements NotificationChannel { /* ... */ }
D — Dependency Inversion Principle
"Depend on abstractions, not on concrete implementations."
This is the most important principle for building flexible systems. Here's a NestJS example:
// ❌ BAD — Service depends on a concrete database implementation
@Injectable()
export class FleetService {
constructor(
private readonly mysqlRepo: MysqlVehicleRepository, // Concrete!
) {}
}
// ✅ GOOD — Service depends on an interface (abstraction)
// Define the contract
export interface VehicleRepository {
findAll(): Promise<Vehicle[]>;
findById(id: string): Promise<Vehicle | null>;
save(vehicle: Vehicle): Promise<Vehicle>;
}
// Implement the contract
@Injectable()
export class TypeOrmVehicleRepository implements VehicleRepository {
constructor(@InjectRepository(Vehicle) private repo: Repository<Vehicle>) {}
async findAll(): Promise<Vehicle[]> { return this.repo.find(); }
async findById(id: string): Promise<Vehicle | null> { return this.repo.findOne({ where: { id } }); }
async save(vehicle: Vehicle): Promise<Vehicle> { return this.repo.save(vehicle); }
}
// Service depends on the abstraction
@Injectable()
export class FleetService {
constructor(
@Inject('VehicleRepository')
private readonly vehicleRepo: VehicleRepository, // Abstraction!
) {}
}
Why? Tomorrow, if you switch from TypeORM to Prisma, you only change the repository implementation — the service doesn't even know the database changed.
Design Patterns in Our Fleet System
| Pattern | Where We Use It | Why |
|---|---|---|
| Repository | Database access layer | Decouple business logic from database |
| Strategy | Notification channels | Different algorithms, same interface |
| Observer | Event/listener system | React to events without tight coupling |
| Factory | Report generation | Create different report types dynamically |
| Builder | Complex query construction | Build queries step by step |
Code Review Checklist (For Mentoring Junior Developers)
When I review code from junior/medior developers, I check these items:
- Does this class do more than one thing? → SRP violation
- Will I need to modify existing code to add this feature? → OCP violation
- Are there
anytypes in TypeScript? → Type safety issue - Are there magic strings/numbers? → Use constants or enums
- Is the function longer than 20 lines? → Probably needs to be split
- Does the test need a real database? → Dependency inversion issue
- Is error handling consistent? → Use custom exception classes
This checklist isn't about being strict — it's about building a shared understanding of quality. When every PR follows these guidelines, the whole team levels up.
What's Next
In Part 7, we'll transform our application from a monolith into microservices. We'll cover service boundaries, inter-service communication, the saga pattern, and — most importantly — when NOT to use microservices.
This is Part 6 of the Fleet Management System series. SOLID isn't academic theory — it's the foundation of every maintainable codebase I've built in 6+ years of professional development.
