Skip to content

Design Principles

This document outlines the key design principles and patterns used throughout the 2KRIKA codebase.

SOLID Principles

Single Responsibility Principle (SRP)

Each class has one reason to change.

Good Example:

// User entity - handles user business rules
class User {
  activate() { }
  hasPermission() { }
}

// User repository - handles user persistence
class UserRepository {
  add() { }
  get() { }
}

// User validator - handles user validation
class UserCreateValidator {
  validate() { }
}

Bad Example:

// God class doing everything
class UserManager {
  activate() { }
  save() { }
  sendEmail() { }
  validate() { }
}

Open/Closed Principle (OCP)

Open for extension, closed for modification.

Good Example:

// Abstract gateway
abstract class PaymentGateway {
  abstract generatePaymentLink(): Promise<PaymentLink>;
}

// Extend without modifying
class PaystackGateway extends PaymentGateway { }
class StripeGateway extends PaymentGateway { }

Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types.

// Any Repository implementation can replace the abstract
abstract class Repository<T> {
  abstract add(item: T): Promise<T>;
}

// All implementations honor the contract
class SqlRepository<T> extends Repository<T> { }
class MongoRepository<T> extends Repository<T> { }

Interface Segregation Principle (ISP)

Clients shouldn't depend on methods they don't use.

Good Example:

// Specific interfaces
interface Readable<T> {
  get(id: string): Promise<T>;
}

interface Writable<T> {
  add(item: T): Promise<T>;
}

// Implement only what you need
class ReadOnlyRepository implements Readable<User> { }

Dependency Inversion Principle (DIP)

Depend on abstractions, not concretions.

Good Example:

// Use case depends on abstraction
async function createUser(
  data: any,
  repository: UserRepository,  // Abstract
  notifier: Notifier,          // Abstract
) {
  // Implementation
}

// Concrete implementations injected at runtime
createUser(data, new UserSqlRepository(), new EmailNotifier());

Design Patterns

Repository Pattern

Abstracts data access logic.

// Domain defines contract
abstract class OrderRepository {
  abstract add(order: Order): Promise<Order>;
  abstract get(id: string): Promise<Order>;
}

// Adapter implements
class OrderSqlRepository extends OrderRepository {
  async add(order: Order): Promise<Order> {
    // SQL implementation
  }
}

Benefits: - Testable (mock repositories) - Swappable implementations - Domain independence

Factory Pattern

Creates complex objects.

// Static factory method
class User {
  static create(
    id: string,
    fullName: string,
    email: string,
    // ...
  ): User {
    return new User(id, fullName, email, /* ... */);
  }
}

// Usage
const user = User.create(id, name, email);

Strategy Pattern

Encapsulates algorithms.

abstract class PaymentGateway {
  abstract generatePaymentLink(): Promise<Link>;
}

class PaystackGateway extends PaymentGateway { }
class StripeGateway extends PaymentGateway { }

// Select strategy at runtime
const gateway = config.usePaystack 
  ? new PaystackGateway() 
  : new StripeGateway();

State Machine Pattern

Manages complex state transitions (XState).

// Order workflow with explicit states and transitions
const orderMachine = createMachine({
  initial: 'paid',
  states: {
    paid: {
      on: {
        'order.accepted': 'inprogress',
        'order.rejected': 'rejected'
      }
    },
    inprogress: {
      on: {
        'order.delivered': 'delivered'
      }
    },
    // ...
  }
});

Observer Pattern

Notification system.

abstract class Notifier {
  abstract notify(user: User, notification: Notification): void;
}

// Different notification strategies
class EmailNotifier extends Notifier { }
class SMSNotifier extends Notifier { }
class PushNotifier extends Notifier { }

Dependency Injection

NestJS provides DI out of the box.

@Module({
  providers: [
    {
      provide: UserRepository,
      useClass: UserSqlRepository,  // Inject concrete implementation
    },
  ],
})
export class UsersModule {}

Coding Principles

Don't Repeat Yourself (DRY)

Extract common logic into reusable functions.

// Shared pagination logic
export function paginate<T>(
  items: T[],
  total: number,
  page: number,
  pageSize: number
): Paginated<T> {
  return {
    results: items,
    total,
    page,
    pageSize,
    totalPages: Math.ceil(total / pageSize),
  };
}

Keep It Simple, Stupid (KISS)

Favor simplicity over cleverness.

// Simple and clear
function isActive(user: User): boolean {
  return user.isActive;
}

// vs overly clever
function isActive(user: User): boolean {
  return !!~[true].indexOf(user.isActive);
}

You Aren't Gonna Need It (YAGNI)

Don't add functionality until it's needed.

❌ Avoid:

// Adding features "just in case"
class User {
  // Maybe we'll need these someday?
  futureFeature1() { }
  futureFeature2() { }
}

Composition Over Inheritance

Prefer composing objects over inheritance.

Good:

class UserService {
  constructor(
    private repository: UserRepository,
    private notifier: Notifier,
    private validator: Validator,
  ) {}
}

Avoid:

class UserService extends Repository 
                   extends Notifier 
                   extends Validator { }

Error Handling

Custom Exceptions

Domain-specific exceptions.

export class ForbiddenError extends Error {
  constructor() {
    super('ForbiddenError');
  }
}

export class ValidationError extends Error {
  constructor(public issues: Record<string, string[]>) {
    super('ValidationError');
  }
}

Exception Filters

Centralized error handling in web layer.

@Catch(ForbiddenError)
export class ForbiddenExceptionFilter {
  catch(exception: ForbiddenError, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse();
    response.status(403).json({
      statusCode: 403,
      message: 'Forbidden',
    });
  }
}

Validation

Zod Validators

Type-safe validation with Zod.

const schema = z.object({
  email: z.string().email(),
  fullName: z.string().min(2),
  password: z.string().min(8),
});

export class UserCreateValidator extends BaseValidator {
  async getValidator() {
    return schema;
  }
}

Validation Errors

Structured error responses.

throw new ValidationError({
  email: ['Email is required'],
  password: ['Password must be at least 8 characters'],
});

Testing Principles

Arrange-Act-Assert (AAA)

Clear test structure.

test('should create user', async () => {
  // Arrange
  const data = { email: 'test@example.com', /* ... */ };
  const mockRepo = { add: jest.fn() };

  // Act
  const result = await createUser(data, mockRepo);

  // Assert
  expect(mockRepo.add).toHaveBeenCalled();
  expect(result.email).toBe('test@example.com');
});

Test Isolation

Each test should be independent.

beforeEach(() => {
  // Fresh setup for each test
  mockRepository = createMockRepository();
});

afterEach(() => {
  // Clean up
  jest.clearAllMocks();
});

Code Organization

Feature Folders

Organize by feature, not by type.

Good:

users/
├── user.entity.ts
├── user.repository.ts
├── user.usecases.ts
└── user.validators.ts

Avoid:

entities/
  └── user.entity.ts
repositories/
  └── user.repository.ts
usecases/
  └── user.usecases.ts

Barrel Exports

Simplify imports with index files.

// domain/user/index.ts
export * from './user.entity';
export * from './user.repository';
export * from './permission.vo';

Next Steps