Writing Clean and Maintainable Code in NestJS
Part 4 of 12
Managing Code Complexity with Abstraction in NestJS
One of the biggest challenges in software development is managing code complexity. As a NestJS application grows, maintaining clean and maintainable code becomes harder. Without proper abstraction, codebases become difficult to read, test, and extend.
In this post, we’ll explore when and how to abstract logic, avoiding over-engineering, and using design patterns effectively.
1. Understanding Code Complexity
Complexity arises when:
✅ Business logic is spread across multiple places
✅ Functions do too many things
✅ Code is hard to change without breaking something else
A common approach to reducing complexity is abstraction, which means separating what code does from how it does it.
2. When and How to Abstract Logic
✅ Good Abstractions: Focus on Reusability & Maintainability
A well-abstracted module:
- Hides implementation details
- Provides a clear, easy-to-use API
- Can be modified without affecting other parts of the system
❌ Bad Abstractions: Over-Engineering
- Creating too many layers unnecessarily
- Abstracting things that won’t be reused
- Making the code harder to follow than the original complexity
Example: Extracting database logic into a repository
❌ Without Abstraction (Mixed Logic in Service)
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async createUser(data: CreateUserDto): Promise<User> {
return this.prisma.user.create({ data });
}
async findUserByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
}
✅ With Abstraction (Using a Repository Layer)
@Injectable()
export class UsersRepository {
constructor(private readonly prisma: PrismaService) {}
async create(data: CreateUserDto): Promise<User> {
return this.prisma.user.create({ data });
}
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
}
@Injectable()
export class UsersService {
constructor(private readonly usersRepo: UsersRepository) {}
async registerUser(data: CreateUserDto): Promise<User> {
return this.usersRepo.create(data);
}
}
Why is this better?
✅ The service focuses on business logic, not database operations.
✅ The repository isolates data access, making it easier to replace Prisma if needed.
3. Avoiding Over-Engineering
While abstraction helps manage complexity, too much abstraction can make code unreadable and unnecessary.
❌ Bad Example: Over-Abstraction
@Injectable()
export class GenericRepository<T> {
constructor(private readonly prisma: PrismaService) {}
async create(data: T): Promise<T> {
return this.prisma.model.create({ data });
}
}
- This generic repository might seem flexible, but it introduces more complexity than benefits.
- Every model might need custom queries, making this abstraction harder to maintain.
✅ Instead, create simple repositories for each entity as needed.
4. Using Design Patterns in NestJS
Design patterns help us write maintainable code while avoiding rigid and overly complex architectures. Let’s look at three useful patterns:
🏗️ 1. Factory Pattern (Encapsulating Object Creation)
The Factory Pattern is useful when you need a clean way to create instances of objects.
✅ Example: Creating Dynamic User Objects
class UserFactory {
static createAdmin(email: string): User {
return new User(email, 'admin');
}
static createCustomer(email: string): User {
return new User(email, 'customer');
}
}
// Usage:
const admin = UserFactory.createAdmin('admin@example.com');
const customer = UserFactory.createCustomer('customer@example.com');
Why use this?
✅ Encapsulates object creation
✅ Prevents redundant code across services
🎭 2. Strategy Pattern (Replacing Conditionals with Pluggable Strategies)
The Strategy Pattern is useful when you need different behaviors for similar operations.
✅ Example: Payment Processing Strategies
interface PaymentStrategy {
pay(amount: number): void;
}
class PayPalPayment implements PaymentStrategy {
pay(amount: number) {
console.log(`Paid ${amount} using PayPal`);
}
}
class CreditCardPayment implements PaymentStrategy {
pay(amount: number) {
console.log(`Paid ${amount} using Credit Card`);
}
}
class PaymentProcessor {
constructor(private strategy: PaymentStrategy) {}
process(amount: number) {
this.strategy.pay(amount);
}
}
// Usage:
const paypalProcessor = new PaymentProcessor(new PayPalPayment());
paypalProcessor.process(100);
Why use this?
✅ Removesif-else
conditions in payment processing
✅ Allows easy extension (add new payment methods without modifying existing code)
🔄 3. Decorator Pattern (Adding Behavior Dynamically)
The Decorator Pattern is useful for adding behavior to a class dynamically.
✅ Example: Logging Decorator for Services
function LogExecution() {
return function (target: any, key: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
console.log(`Executing ${key} with args:`, args);
const result = await originalMethod.apply(this, args);
console.log(`Result:`, result);
return result;
};
};
}
class OrderService {
@LogExecution()
async placeOrder(orderId: number) {
return `Order ${orderId} placed successfully!`;
}
}
// Usage:
const service = new OrderService();
service.placeOrder(123);
Why use this?
✅ Adds logging behavior without modifying the original method
✅ Keeps code clean & modular
5. Summary
✅ Best Practices for Managing Code Complexity in NestJS
- Abstract logic properly – Hide implementation details but don’t overdo it.
- Use a repository layer – Keep services focused on business logic.
- Avoid unnecessary abstractions – Keep code simple where possible.
- Leverage design patterns – Use Factory, Strategy, and Decorator patterns to organize logic.
What strategies do you use to manage complexity in your NestJS projects? Let’s discuss in the comments!