SOLID Principles
The SOLID design principles guide the object-oriented and functional structure of both the Fastify backend and React frontend to maintain highly modular, testable, and robust systems.
1. Single Responsibility Principle (SRP)
Concept & Overview
A module, class, or function should have one, and only one, reason to change, meaning it must encapsulate a single well-defined task. SRP reduces coupling and guarantees that a change in one system requirement only affects a single isolated module.
Project-Specific Utilization
Backend (Fastify API)
We enforce a strict layered architecture:
Controller -> Service -> Repository -> Model
- Controllers only handle raw request/response routing, payload parsing, and Zod validation.
- Services exclusively orchestrate business rules, transaction boundaries, and integrations.
- Repositories handle raw database queries and mutations.
- Models define Mongoose schema definitions and validation structures.
Frontend (React Web App)
React components are decoupled from state management and async fetching:
- Zustand stores are restricted to state mutations and raw action triggers.
- Custom React hooks (
src/domains/*/hooks) handle state selectors and trigger actions, keeping React UI components strictly focused on rendering.
2. Open/Closed Principle (OCP)
Concept & Overview
Software modules should be open for extension but closed for modification. New features or behaviors should be added by extending code (e.g., through inheritance, composition, or plugins), not by altering existing, verified logic.
Project-Specific Utilization
Backend (Fastify API)
Fastify's plugin and decorator ecosystem is an ideal implementation of OCP. We extend Fastify's core features (like adding authentication, logging, or database clients) by registering plugins using fastify-plugin without altering the main server bootstrapper:
// Registering a core authentication decorator securely
// instance/apps/api/src/plugins/auth.ts
import fp from 'fastify-plugin';
export default fp(async (fastify) => {
fastify.decorate('authenticate', async (request, reply) => {
// Authenticator logic here...
});
});
Frontend (React Web App)
We rely extensively on React Component Composition instead of bloating single components with conditional flags. Generic UI elements (tables, dialogs, cards) accept layout customizers via children properties or typed render functions, avoiding complex internal if-else logic:
// Open to extension via composition, closed to modification
interface CardProps {
title: string;
children: React.ReactNode;
actions?: React.ReactNode;
}
export const PremiumCard = ({ title, children, actions }: CardProps) => (
<div className="card-container">
<div className="card-header">
<h3>{title}</h3>
{actions !== undefined && <div className="card-actions">{actions}</div>}
</div>
<div className="card-body">{children}</div>
</div>
);
3. Liskov Substitution Principle (LSP)
Concept & Overview
Subtypes must be completely substitutable for their base types without altering the correctness, behavior, or invariants of the application. If a service depends on an interface, any class implementing that interface must be swappable without breaking client code.
Project-Specific Utilization
File Storage Adapters
All external adapters, cache stores, and database models are designed against strict, unified interfaces. For example, our file upload managers implement a shared, typed contract:
export interface IFileStorageAdapter {
uploadFile(file: Buffer, path: string): Promise<string>;
deleteFile(path: string): Promise<void>;
}
In development, we inject a LocalDiskStorageAdapter that writes to tmp/. In production and staging, we inject a CloudflareR2StorageAdapter or S3StorageAdapter. Both adapters implement IFileStorageAdapter perfectly. The consuming service (e.g., ProductImageService) remains 100% oblivious to the actual underlying system, and we can swap them out seamlessly without breaking service logic.
4. Interface Segregation Principle (ISP)
Concept & Overview
Clients should not be forced to depend on interfaces or methods they do not utilize. Lean, specific interfaces are always superior to a single, general-purpose monolithic contract.
Project-Specific Utilization
Typed Sub-contracts
We avoid exposing large, monolithic models or database documents directly to layers that do not need them. Instead, we declare precise sub-interfaces and Zod schemas inside the @tupynambalucas-hub/core library:
// Monolithic database representation (Internal only)
export interface IUserDocument extends Document {
id: string;
email: string;
passwordHash: string;
username: string;
role: 'admin' | 'user';
createdAt: Date;
updatedAt: Date;
}
// Segregated contracts for downstream layers
export interface IUserPublicSession {
id: string;
email: string;
role: 'admin' | 'user';
}
export interface ICreateUserDTO {
email: string;
username: string;
}
Consuming modules, such as frontend header components, only import and rely on the narrow IUserPublicSession rather than the bloated database document, ensuring that changing password hash rules does not trigger refactoring on client views.
5. Dependency Inversion Principle (DIP)
Concept & Overview
High-level modules must not depend directly on low-level modules; both must depend on abstractions (e.g., interfaces or abstract classes). Details must depend on abstractions, not the other way around.
Project-Specific Utilization
Backend (Dependency Injection)
Services never instantiate repositories, databases, or third-party SDKs directly inside their constructors. Instead, dependencies are passed to services via constructor injection:
import type { Model } from 'mongoose';
import type { IUser } from '@tupynambalucas-hub/core';
export class AuthRepository {
// Injected model, decoupled from concrete instantiation
constructor(private readonly userModel: Model<IUser>) {}
async findByEmail(email: string): Promise<IUser | null> {
return this.userModel.findOne({ email }).exec();
}
}
This decoupling allows us to easily inject mocked Mongoose models in our unit tests, completely isolating the database from our test suites.
Frontend (Service Abstractions)
React components do not directly call Axios endpoints or access localStorage. Instead, they interact with custom domain hooks and select methods from Zustand stores. The concrete implementation of the HTTP client and browser persistence is hidden behind the domain layer's abstraction, allowing us to change the network library (e.g., swapping Axios for Fetch) without touching our UI components.