DEV Community

Cover image for Building Maintainable Backends with Port-Adapter Architecture in NestJS
Raj Maharjan
Raj Maharjan

Posted on • Originally published at rajmaharjan.com

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

Stripe Adapter

@Injectable()
export class StripePaymentAdapter implements PaymentPort {
  async charge(amount: number, currency: string, source: string) {
    // Stripe-specific implementation
  }
}
Enter fullscreen mode Exit fullscreen mode

Another Payment Adapter (Future)

@Injectable()
export class EsewaPaymentAdapter implements PaymentPort {
  async charge(amount: number, currency: string, source: string) {
    // Esewa-specific implementation
  }
}
Enter fullscreen mode Exit fullscreen mode

Service Using the Port

constructor(
  @Inject(PAYMENT_SERVICE)
  private readonly paymentService: PaymentPort,
) {}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)