
Building Maintainable Backends with Port-Adapter Architecture in NestJS
TL;DR
Port-Adapter Architecture helps keep business logic separate from infrastructure like databases and external services, making your NestJS app easier to test and change. Use it only where flexibility matters, not everywhere.
Introduction
When we start backend development, most of us write code like this:
- Controller calls Service
- Service directly talks to Database
- Service also calls email, SMS, payment, etc.
This works fine at the beginning. But as the project grows, things slowly become messy. One small change breaks many places. Testing becomes hard. New developers feel lost.
This is where Port-Adapter Architecture (also known as Hexagonal Architecture) helps. It is not magic and not mandatory everywhere, but when used correctly, it makes backend code clean, understandable, and future-proof.
In this article, we will understand:
- What Port-Adapter Architecture really is (in simple words)
- Why and when to use it
- How it compares to other approaches
- A clear folder structure
- When NOT to use ports
What Problem Are We Actually Solving?
Imagine this service code:
// ProductService
await this.productModel.create(data);
await axios.post("email-service/send");
await redis.set(key, value);
Problems here:
- Database logic inside service
- External API logic inside service
- Hard to test
- Hard to replace MongoDB or email provider
- Too many responsibilities in one place
This creates tight coupling. Tight coupling makes future changes painful.
What is Port-Adapter Architecture (Hexagonal Architecture)?
Port-Adapter Architecture separates thinking from doing.
- Port = What the application needs (interface)
- Adapter = How it is actually done (real implementation)
Example:
- "I need to save a product" → Port
- "I save it using MongoDB" → Adapter
If tomorrow you want PostgreSQL, you only change the adapter. Business logic stays the same.
Why Port-Adapter vs Other Common Approaches?
1. Traditional Service → Repository (No Ports)
Controller → Service → Repository
This approach is completely fine for:
- Small applications
- MVPs
- Side projects
But it becomes a problem when:
- Service depends on many external systems
- Testing requires mocking many things
- You want to replace implementations
2. Clean Architecture
Clean Architecture is powerful, but:
- Too many layers
- Too much boilerplate
- Hard for beginners
- Slower development for small teams
3. Why Port-Adapter is a Good Balance
Port-Adapter gives:
- Clear boundaries
- Less coupling
- Easier testing
- Flexibility
Without being too complex or academic.
Important Rule: Ports Are NOT Required for Everything
This is very important.
Do NOT create ports for:
- Simple CRUD repositories
- Internal helper services
- Utility functions
- Logic that will never change
Use ports for:
- External services (email, SMS, payment)
- Cross-module dependencies
- Business-critical operations
- Things that may change in the future
Architecture should reduce complexity, not increase it.
Beginner-Friendly Folder Structure
src/
│
├── product/
│ ├── controllers/
│ │ └── product.controller.ts
│ │
│ ├── services/
│ │ └── product.service.ts
│ │
│ ├── ports/
│ │ └── product.port.ts
│ │
│ ├── repositories/
│ │ └── product.repository.ts
│ │
│ ├── dto/
│ │ ├── create-product.dto.ts
│ │ └── update-product.dto.ts
│ │
│ ├── schemas/
│ │ └── product.schema.ts
│ │
│ ├── product.tokens.ts
│ └── product.module.ts
│
├── inventory/
│ ├── ports/
│ ├── services/
│ └── inventory.module.ts
│
├── notification/
│ ├── ports/
│ ├── services/
│ └── notification.module.ts
│
└── common/
├── base/
├── utils/
└── constants/
Why This Structure Works
- Easy for new developers to understand
- Clear separation of responsibilities
- Business logic is easy to find
- Infrastructure code is isolated
Where Ports Make the Most Sense
A very common and practical example is payment or transaction handling.
Imagine your application needs to process payments. Today you might use Stripe, but tomorrow the business may ask to support another payment partner like Khalti, Esewa, or any other gateway.
If you directly write Stripe code inside your service, changing the payment provider later will be painful and risky.
This is where a port makes perfect sense.
Payment Port Example
// payment.port.ts
export interface PaymentPort {
charge(
amount: number,
currency: string,
source: string
): Promise<PaymentResult>;
}
Stripe Adapter
@Injectable()
export class StripePaymentAdapter implements PaymentPort {
async charge(amount: number, currency: string, source: string) {
// Stripe-specific implementation
}
}
Another Payment Adapter (Future)
@Injectable()
export class EsewaPaymentAdapter implements PaymentPort {
async charge(amount: number, currency: string, source: string) {
// Esewa-specific implementation
}
}
Service Using the Port
constructor(
@Inject(PAYMENT_SERVICE)
private readonly paymentService: PaymentPort,
) {}
Now your business logic does not care whether the payment is done by Stripe, Esewa, or any other provider. You can switch implementations from the module without touching the service code.
This is an ideal and real-world use case for Port-Adapter Architecture.
When You Should Avoid Ports
If your code is:
await this.userRepository.findById(id);
And:
- Repository is local
- No external dependency
- No plan to replace it
Then do not create a port. Keep it simple.
Simple Mental Model
Before creating a port, ask:
"Will I ever want to replace or mock this?"
- Yes → Use a port
- No → Do not use a port
Conclusion
Port-Adapter Architecture is a tool, not a rule.
Use it when:
- Project is growing
- Multiple developers are working together
- Business logic is important
- Testing matters
Avoid it when:
- Application is small
- Logic is simple
- Abstraction adds confusion
In NestJS, this pattern fits naturally and helps keep code clean without becoming too complex. Start small and apply it only where it makes sense.
Raj Maharjan
@dubbyding