This article is available in Indonesian

🇮🇩 Baca dalam Bahasa Indonesia

May 22, 2026

🇬🇧 English

Fleet Management Part 6: SOLID Principles & Design Patterns in Practice

Apply SOLID principles and design patterns in real code. Refactor bad code to clean code with practical examples in NestJS and Laravel.

7 min read

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.

LetterPrincipleSimple Analogy
SSingle ResponsibilityA chef cooks. A waiter serves. Don't make the chef serve tables.
OOpen/ClosedA power strip accepts new plugs without being rewired.
LLiskov SubstitutionIf you order a "vehicle", any vehicle (car, truck, van) should work.
IInterface SegregationA TV remote shouldn't have buttons for the DVD player.
DDependency InversionA 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

PatternWhere We Use ItWhy
RepositoryDatabase access layerDecouple business logic from database
StrategyNotification channelsDifferent algorithms, same interface
ObserverEvent/listener systemReact to events without tight coupling
FactoryReport generationCreate different report types dynamically
BuilderComplex query constructionBuild queries step by step

Code Review Checklist (For Mentoring Junior Developers)

When I review code from junior/medior developers, I check these items:

  1. Does this class do more than one thing? → SRP violation
  2. Will I need to modify existing code to add this feature? → OCP violation
  3. Are there any types in TypeScript? → Type safety issue
  4. Are there magic strings/numbers? → Use constants or enums
  5. Is the function longer than 20 lines? → Probably needs to be split
  6. Does the test need a real database? → Dependency inversion issue
  7. 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.

Continue Reading

Previous article

← Previous Article

Fleet Management Part 5: Database Design — PostgreSQL, MySQL & Redis

Next Article →

Fleet Management Part 7: From Monolith to Microservices

Next article