Onion Architecture : An Example Folder Structure — Nest.js

Debasis Das
5 min readAug 31, 2023

In this article, we will delve into the key concepts of Onion Architecture and provide an example folder structure that illustrates its implementation.

A. What is onion architecture : As shown in the above picture, onion architecture is a way to structure the code by dividing it into domain-driven design layers. each layer can only access the layer below it throw its interfaces, and then using the dependency inversion principle each interface will be replaced with its class.

B. Key tenets of Onion Architecture:

  1. The application is built around an independent object model
  2. Inner layers define interfaces. Outer layers implement interfaces
  3. Direction of coupling is toward the center
  4. All application core code can be compiled and run separate from infrastructure

C. Layers of the Onion:

  • Domain Layer ( Domain model and Domain service): Contains the core business logic, entities, and business rules of the application.
  • Application Layer: Implements use cases and coordinates the flow of data between the domain and infrastructure layers.
  • Infrastructure Layer: Handles external concerns such as databases, file systems, or external services.
  • API Layer: API is an interface that use to exchange information securely between frontend and backed server.

D. Example Folder Structure : Lets demonstrate a common folder structure based on Onion Architecture —

  • Domain Layer — Domain model : Contains the core entities. src/domain/entities
export class EmployeeEN {
FirstName: string;
SurName: string;
Gender: string;
Designation: string;
Email: string;
Address: string;
Balance: number;
}
  • Domain Layer — Domain service : Contains the core business logic and business rules of the application. src/domain/domain_services
import { Injectable } from '@nestjs/common';
import { EmployeeEN } from 'src/domain';
import { CreateAuthorDto } from 'src/domain/Dto/employee.dto';

@Injectable()
export class EmployeeFactoryService {
getBalance (employee : EmployeeEN): number {
return employee?.Balance;
}

canWithdrawBalance (employee : EmployeeEN, amount : number): boolean {
return (employee.Balance - amount) > 0;
}

withdrawBalance (employee : EmployeeEN, amount : number): EmployeeEN {
employee.Balance = employee?.Balance - amount;
return employee;
}
}
  • Application Layer/ Usecase Layer: Implements use cases and coordinates the flow of data between the domain and infrastructure layers. src/useCases/
import { Injectable } from "@nestjs/common";
import { EmployeeServiceDB } from '../../infastructure/data-services/mongoDB/employee.service';
import { IDataServices } from "src/domain/abstracts/data-services.abstract";
import { CreateAuthorDto } from "src/domain/Dto/employee.dto";
import { EmployeeFactoryService } from "../../domain/domain_service/employee-factory";
import { error } from "console";


@Injectable()
export class EmployeeUseCases {
constructor(
private dataServices: IDataServices,
private employeeFactoryService: EmployeeFactoryService
) { }

getAllEmployee(): Promise<any> {
return this.dataServices.empoyees.getAll();
}

createEmployee(employeeDto: CreateAuthorDto): Promise<any> {
const employee = this.employeeFactoryService.createnewEmployee(employeeDto);
return this.dataServices.empoyees.create(employee);
}

async withdrawBalance (email: string, balance: number) : Promise<number> {
let employee = await this.dataServices.empoyees.get(email);
console.log("Employeee :::: ", employee);
if(!this.employeeFactoryService.canWithdrawBalance(employee, balance))
throw error("Less Balance");

employee = this.employeeFactoryService.withdrawBalance(employee, balance);

console.log("employee ::: ", employee);

const updatedEmployee = await this.dataServices.empoyees.update(email, employee);

console.log("updatedEmployee :::: ", updatedEmployee);

return updatedEmployee?.balance;
}
}
  • Infrastructure Layer: Handles external concerns such as databases, file systems, or external services. src/infrastrucure/dataservices/mongodb, src/infrastrucure/dataservices/queueService
import {
Injectable
} from '@nestjs/common';

import {
Model
} from 'mongoose';

import { IGenericRepository } from 'src/domain/abstracts';

@Injectable()
export class EmployeeServiceDB<T> implements IGenericRepository<T> {
private _repository: Model<T>;
private _populateOnFind: string[];

constructor(repository: Model<T>, populateOnFind: string[] = []) {
this._repository = repository;
this._populateOnFind = populateOnFind;
}

getAll(): Promise<T[]> {
return this._repository.find().populate(this._populateOnFind).exec();
}

create(item: T): Promise<T> {
return this._repository.create(item);
}

async get(email: string) {
return await this._repository.findOne({Email : email}).populate(this._populateOnFind).exec();
}

update(email: string, item: T) {
return this._repository.findOneAndUpdate({Email : email}, item);
}
}
  • API Layer: API is an interface that use to exchange information securely between frontend and backed server. src/controller
import {
Controller,
Get,
Post,
Body,
Param,
Delete,
Put
} from '@nestjs/common';
import { CreateAuthorDto } from 'src/domain/Dto/employee.dto';
import { UpdateEmployeeDto } from 'src/domain/Dto/employee.update.dto';
import { EmployeeUseCases } from 'src/useCases/employee/employee.usecase';


@Controller('api/employee')
export class EmployeeController {
constructor(private readonly employeeUseCases: EmployeeUseCases) { }

@Get()
findAll() {
return this.employeeUseCases.getAllEmployee();
}

@Post()
createAuthor(@Body() employeeDto: CreateAuthorDto) {
return this.employeeUseCases.createEmployee(employeeDto);
}

@Put(':id')
updateEmployee(
@Param('id') email: string,
@Body() updateEmployeeDto: UpdateEmployeeDto,
) {
try{
return this.employeeUseCases.withdrawBalance(email, updateEmployeeDto?.Balance);
}catch(err){
throw err;
}
}
}

E. Benefits of this architecture pattern:

  • Independent of Frameworks: The architecture does not depend on the existence of some library of feature laden software. This allows you to use such frameworks as tools, rather than having to cram your system into their limited constraints.
  • Testable: The business rules can be tested without the UI, Database, Web Server, or any other external element.
  • Independent of UI: The UI can change easily, without changing the rest of the system. A Web UI could be replaced with a console UI, for example, without changing the business rules.
  • Independent of Database: You can swap out Oracle or SQL Server, for Mongo, BigTable, CouchDB, or something else. Your business rules are not bound to the database.
  • Independent of any external agency: In fact your business rules simply don’t know anything at all about the outside world.

Conclusion:

Problems Onion Architecture solves

  • A more structured, layered layout of the code makes code navigation easier and makes the relationship between different parts of the codebase more visible at first glance
  • Loose coupling between the domain and the infrastructure
  • Coupling is towards the centre of The Onion — expressed by the relationship between the layers
  • (Usually) No coupling between the domain and the infrastructure concerns of the application
  • Build tool support in enforcing layers

Problems Onion Architecture creates

  • Additional learning curve for new developers, and those used to other architecture styles
  • Increased overall complexity of the codebase — especially with the flavour utilising the modularizing capabilities of build tools such as Gradle or Maven
  • Not everyone likes the smell of it

--

--

Debasis Das

Senior software developer | Typescript | Node.js | Micro-service | Large scale distributed system | LLD | HLD | Reactjs