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:
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:
❌ Avoid:
Barrel Exports
Simplify imports with index files.
// domain/user/index.ts
export * from './user.entity';
export * from './user.repository';
export * from './permission.vo';