Adapters Layer
The Adapters Layer implements the infrastructure needed to persist data, communicate with external systems, and provide concrete implementations of domain interfaces. Located in the adapters/ directory, this layer translates between the domain's abstract requirements and real-world technologies.
Overview
The Adapters Layer:
- Implements repository interfaces defined in the domain
- Manages database access using Sequelize (SQL) and Mongoose (MongoDB)
- Integrates external services (payment gateways, storage)
- Handles data mapping between domain entities and database models
- Provides infrastructure abstractions (cache, file system)
Repository Implementations
Repositories bridge the gap between domain entities and database persistence.
SQL Repository Base
// adapters/shared/repository/sql.ts
export abstract class SqlRepository<
T extends Identifiable<ID>,
ID = Identifier,
F = any,
> extends AbstractRepository<T, ID, F> {
protected model: ModelStatic<Model>;
constructor(model: ModelStatic<Model>) {
super();
this.model = model;
}
async add(item: T, opts: SqlWriteOptions = {}): Promise<T> {
const dump = this.dumps(item);
try {
const createdItem = await this.model.create(dump, {
transaction: opts.transaction,
});
return this.loads(createdItem);
} catch (e: any) {
logger.error(`Unable to save: ${e.stack}`);
if (e instanceof UniqueConstraintError) {
throw new AlreadyExists();
}
throw e;
}
}
async get(id: ID): Promise<T> {
const item = await this.model.findByPk(id as Identifier);
if (item === null) {
throw new NotFound();
}
return this.loads(item);
}
async update(id: ID, data: Partial<T>, opts: SqlWriteOptions = {}): Promise<T> {
await this.model.update(data, {
where: { id },
transaction: opts.transaction,
});
return await this.get(id);
}
async delete(id: ID, opts: SqlWriteOptions = {}): Promise<void> {
await this.model.destroy({
where: { id },
transaction: opts.transaction
});
}
async all(filters: any & Pagination = {}): Promise<Paginated<T>> {
filters = {
...Pagination.default(),
...filters,
};
const { page, pageSize, ...rest } = filters;
const items = await this.model.findAndCountAll({
where: this.queryPolicy(rest),
order: this.parseOrderBy(filters.orderBy) || this.defaultOrdering(),
limit: pageSize,
offset: (page - 1) * pageSize,
});
return {
results: items.rows.map((item) => this.loads(item)),
count: items.count,
page: page,
pageSize: pageSize,
};
}
// Child classes must implement these
protected abstract loads(instance: Model): T;
protected dumps(item: T): Record<string, any> {
return { ...item, updatedAt: new Date() };
}
// Override for custom filtering
protected queryPolicy(filters: F): Record<any, any> {
return {};
}
// Transaction support
begin(): Promise<Transaction> {
return db.sequelize.transaction();
}
// ID generation
nextId(): ID {
return createId() as ID;
}
}
Key features: - Generic base class – Reusable across all SQL repositories - Transaction support – Automatic transaction handling - Error mapping – Converts SQL errors to domain exceptions - Pagination – Built-in pagination support - Query policies – Subclasses define custom filters
Concrete Repository Example
// adapters/user/user.repository.ts
@Injectable()
class UserSqlRepository
extends SqlRepository<User, string, UserQueryPolicy>
implements UserRepository
{
constructor() {
super(db.User); // Pass Sequelize model
}
// Custom query filtering
protected queryPolicy(filters: UserQueryPolicy): Record<any, any> {
const policy: Record<any, any> = {};
if (filters.search) {
Object.assign(policy, {
[Op.or]: [
{ fullName: { [Op.like]: `%${filters.search}%` } },
{ email: { [Op.like]: `%${filters.search}%` } },
],
});
}
if (filters.isActive !== undefined) {
Object.assign(policy, { isActive: filters.isActive });
}
if (filters.isKYCVerified !== undefined) {
Object.assign(policy, { isKYCVerified: filters.isKYCVerified });
}
return policy;
}
// Override for complex queries with joins
async all(filters?: UserQueryPolicy): Promise<Paginated<User>> {
filters = {
...Pagination.default(),
...filters,
};
const joins = [];
// Add role filter if specified
if (filters.roles && filters.roles.length > 0) {
const rolesIdsMap = {
customer: Role.CUSTOMER.id,
seller: Role.SELLER.id,
admin: Role.ADMIN.id,
};
const roles = filters.roles
.filter((r) => ["customer", "seller", "admin"].includes(r))
.map((r) => rolesIdsMap[r]);
if (roles.length > 0) {
joins.push({
model: db.Role,
where: { id: { [Op.in]: roles } },
});
}
}
const { page, pageSize, ...rest } = filters;
const items = await this.model.findAndCountAll({
where: this.queryPolicy(rest),
order: this.parseOrderBy(filters.orderBy) || this.defaultOrdering(),
limit: pageSize,
offset: (page - 1) * pageSize,
include: joins,
});
return {
results: items.rows.map((item) => this.loads(item)),
count: items.count,
page: page,
pageSize: pageSize,
};
}
// Domain-specific repository methods
async approveKyc(id: string, opts: any = {}): Promise<void> {
await this.model.update(
{ isKYCVerified: true },
{ where: { id }, transaction: opts.transaction }
);
}
async getKYCValidationDate(userId: string): Promise<Date | null> {
const identity = await db.Identity.findOne({
where: {
userId,
status: IdentityStatus.APPROVED,
},
});
return identity ? identity.getDataValue("KYCValidationDate") : null;
}
async filterByIds(ids: string[]): Promise<User[]> {
const items = await this.model.findAll({
where: { id: { [Op.in]: ids } },
});
return items.map((item) => this.loads(item));
}
// Data mapping: Database model → Domain entity
loads(instance: Model): User {
return User.create(
instance.getDataValue("id"),
instance.getDataValue("fullName"),
instance.getDataValue("username"),
instance.getDataValue("email"),
instance.getDataValue("passwordHash"),
instance.getDataValue("lastLogin"),
instance.getDataValue("isActive"),
instance.getDataValue("isStaff"),
instance.getDataValue("isSuperuser"),
instance.getDataValue("isKYCVerified"),
instance.getDataValue("title"),
instance.getDataValue("description"),
instance.getDataValue("notes"),
instance.getDataValue("createdAt"),
instance.getDataValue("updatedAt"),
);
}
// Data mapping: Domain entity → Database model
dumps(item: User): Record<string, any> {
return { ...item };
}
}
Key implementation details:
- Query policy – Transforms domain filters to SQL WHERE clauses
- Joins – Handles complex relationships (users with roles)
- Domain-specific methods – Implements UserRepository interface
- Data mapping – loads() and dumps() convert between layers
- Injectable – NestJS decorator for dependency injection
Database Models
Sequelize models define the database schema.
Model Example
// adapters/shared/sql/models/user.model.ts
import { DataTypes, Model } from "sequelize";
import sequelize from "../connection";
export interface UserAttributes {
id: string;
fullName: string;
username: string;
email: string;
passwordHash: string;
isActive: boolean;
isStaff: boolean;
isSuperuser: boolean;
title: string;
description: string;
notes: string;
lastLogin: Date | null;
isKYCVerified: boolean;
createdAt: Date;
updatedAt: Date;
}
class UserModel extends Model<UserAttributes> implements UserAttributes {
declare id: string;
declare fullName: string;
declare username: string;
declare email: string;
declare passwordHash: string;
declare isActive: boolean;
declare isStaff: boolean;
declare isSuperuser: boolean;
declare title: string;
declare description: string;
declare notes: string;
declare lastLogin: Date | null;
declare isKYCVerified: boolean;
declare createdAt: Date;
declare updatedAt: Date;
}
UserModel.init(
{
id: {
type: DataTypes.STRING,
primaryKey: true,
},
fullName: {
type: DataTypes.STRING,
allowNull: false,
},
username: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
},
email: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
},
passwordHash: {
type: DataTypes.STRING,
allowNull: false,
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
isStaff: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
isSuperuser: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
isKYCVerified: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
title: DataTypes.TEXT,
description: DataTypes.TEXT,
notes: DataTypes.TEXT,
lastLogin: DataTypes.DATE,
createdAt: DataTypes.DATE,
updatedAt: DataTypes.DATE,
},
{
sequelize,
tableName: "users",
timestamps: true,
}
);
export default UserModel;
Key features:
- Type safety – TypeScript interfaces for model attributes
- Constraints – Unique fields, not null, defaults
- Timestamps – Automatic createdAt and updatedAt
- Sequelize-specific – Framework code stays in adapters
Model Associations
// adapters/shared/sql/models/associations.ts
import User from "./user.model";
import Role from "./role.model";
import UserRole from "./userrole.model";
import Order from "./order.model";
import Service from "./service.model";
// Many-to-many: Users <-> Roles
User.belongsToMany(Role, {
through: UserRole,
foreignKey: "userId",
otherKey: "roleId",
});
Role.belongsToMany(User, {
through: UserRole,
foreignKey: "roleId",
otherKey: "userId",
});
// One-to-many: User -> Services
User.hasMany(Service, {
foreignKey: "ownerId",
as: "services",
});
Service.belongsTo(User, {
foreignKey: "ownerId",
as: "owner",
});
// One-to-many: User -> Orders (as seller)
User.hasMany(Order, {
foreignKey: "sellerId",
as: "ordersAsSeller",
});
// One-to-many: User -> Orders (as customer)
User.hasMany(Order, {
foreignKey: "customerId",
as: "ordersAsCustomer",
});
External Service Integrations
Payment Gateway
// adapters/order/paystack.gateway.ts
import { PaymentGateway, InitializePaymentData } from "@/domain/payments/payment.gateway";
import axios from "axios";
export class PaystackGateway implements PaymentGateway {
private readonly secretKey: string;
private readonly baseUrl = "https://api.paystack.co";
constructor() {
this.secretKey = process.env.PAYSTACK_SECRET_KEY!;
}
async initializePayment(data: InitializePaymentData): Promise<any> {
const response = await axios.post(
`${this.baseUrl}/transaction/initialize`,
{
email: data.email,
amount: data.amount * 100, // Paystack uses kobo
reference: data.reference,
callback_url: data.callbackUrl,
},
{
headers: {
Authorization: `Bearer ${this.secretKey}`,
"Content-Type": "application/json",
},
}
);
return response.data;
}
async verifyPayment(reference: string): Promise<any> {
const response = await axios.get(
`${this.baseUrl}/transaction/verify/${reference}`,
{
headers: {
Authorization: `Bearer ${this.secretKey}`,
},
}
);
return response.data;
}
}
Key features:
- Implements domain interface – Adheres to PaymentGateway contract
- External API calls – Uses axios for HTTP requests
- Configuration – Reads credentials from environment variables
- Data transformation – Converts domain data to Paystack format
Workflow State Machine
// adapters/order/order.workflow.ts
import { createActor, createMachine } from "xstate";
import { Order } from "@/domain/orders/order.entity";
import { User } from "@/domain/user/user.entity";
import { Notifier } from "@/shared/notifications/base";
export default function actorFactory(
order: MachineOrder,
notifier: Notifier,
dependencies: RepositoryDependency,
) {
const machine = createMachine({
context: {
order: order,
cancellationRequestCount: order.cancelationRequestCount,
},
initial: order.state,
states: {
paid: {
on: {
"order.rejected": {
target: "rejected",
guard: "isSeller",
actions: ["sendDeclinedOrderMessage", "createRefund"],
},
"order.accepted": {
target: "inprogress",
guard: "isSeller",
actions: "sendAcceptedOrderMessage",
},
},
},
inprogress: {
on: {
"order.delivered": {
target: "delivered",
guard: "isSeller",
},
"order.cancel_requested": [
{
target: "cancelled",
guard: "isSeller",
actions: "sendOrderCancelledMessage",
},
{
target: "cancel_requested",
actions: "sendCancellationRequestedMessage",
},
],
},
},
delivered: {
on: {
"order.completed": {
target: "completed",
guard: "isCustomer",
actions: ["releasePayment", "sendCompletedOrderMessage"],
},
"order.requires_correction": {
target: "correction_requested",
guard: "isCustomer",
actions: "sendCorrectionRequestedMessage",
},
},
},
// ... more states
},
},
{
guards: {
isSeller: ({ context, event }) => {
return context.order.seller.id === event.by.id;
},
isCustomer: ({ context, event }) => {
return context.order.customer.id === event.by.id;
},
},
actions: {
sendAcceptedOrderMessage: ({ context }) => {
notifier.sendAcceptedOrderMessage(
context.order.customer,
context.order.serviceTitle,
context.order.id,
);
},
// ... more actions
},
});
return createActor(machine);
}
Key features:
- XState integration – Uses state machine library
- Guards – Validate state transitions based on user role
- Actions – Side effects (notifications, payments)
- Domain-driven – States match domain Order states
Storage Adapters
Local File System
// adapters/storage/local.ts
import { FileSystem, File } from "@/app/shared/filesystem";
import { createId } from "@paralleldrive/cuid2";
import fs from "fs/promises";
import path from "path";
@Injectable()
export class LocalFileSystem implements FileSystem {
private readonly basePath: string;
constructor() {
this.basePath = process.env.STORAGE_PATH || "./storage/uploads";
}
async write(file: File): Promise<string> {
const ext = file.name.split(".").pop();
const filename = `${createId()}.${ext}`;
const filepath = path.join(this.basePath, filename);
await fs.mkdir(path.dirname(filepath), { recursive: true });
await fs.writeFile(filepath, Buffer.from(await file.arrayBuffer()));
return filename; // Return relative path
}
async read(filepath: string): Promise<Buffer> {
const fullPath = path.join(this.basePath, filepath);
return await fs.readFile(fullPath);
}
async delete(filepath: string): Promise<void> {
const fullPath = path.join(this.basePath, filepath);
await fs.unlink(fullPath);
}
async exists(filepath: string): Promise<boolean> {
try {
await fs.access(path.join(this.basePath, filepath));
return true;
} catch {
return false;
}
}
}
Key features:
- Implements abstract interface – FileSystem from application layer
- File naming – Generates unique filenames with CUID
- Directory management – Auto-creates directories
- Error handling – Graceful handling of missing files
Cache Adapter
// adapters/shared/cache/redis.ts
import { AbstractCache } from "@/app/shared/cache";
import Redis from "ioredis";
import { Injectable } from "@nestjs/common";
@Injectable()
export class RedisCache implements AbstractCache {
private client: Redis;
constructor() {
this.client = new Redis({
host: process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.REDIS_PORT || "6379"),
password: process.env.REDIS_PASSWORD,
});
}
async get<T = any>(key: string): Promise<T> {
const value = await this.client.get(key);
if (!value) return null as T;
return JSON.parse(value) as T;
}
async set(key: string, value: any, ttl?: number): Promise<void> {
const serialized = JSON.stringify(value);
if (ttl) {
await this.client.setex(key, ttl, serialized);
} else {
await this.client.set(key, serialized);
}
}
async delete(key: string): Promise<void> {
await this.client.del(key);
}
async clear(): Promise<void> {
await this.client.flushdb();
}
}
Database Connection
// adapters/shared/db.ts
import { Sequelize } from "sequelize";
import mongoose from "mongoose";
// SQL connection
export const sequelize = new Sequelize({
dialect: "postgres",
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || "5432"),
database: process.env.DB_NAME,
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
logging: process.env.NODE_ENV === "development" ? console.log : false,
});
// MongoDB connection
export const mongooseConnection = mongoose.connect(
process.env.MONGO_URI || "mongodb://localhost:27017/2krika",
{
useNewUrlParser: true,
useUnifiedTopology: true,
}
);
// Export all models
import User from "./sql/models/user.model";
import Role from "./sql/models/role.model";
import Service from "./sql/models/service.model";
// ... more models
export default {
sequelize,
User,
Role,
Service,
// ... more models
};
Adapters Module Organization
adapters/
├── account/ # Account persistence
│ ├── account.repository.ts
│ ├── account_event.repository.ts
│ ├── refund.repository.ts
│ └── withdraw.repository.ts
├── order/ # Order persistence and workflow
│ ├── order.repository.ts
│ ├── order.workflow.ts
│ ├── payment.repository.ts
│ ├── payment_event.repository.ts
│ ├── paystack.gateway.ts
│ └── chat.repository.ts
├── service/ # Service persistence
│ ├── service.repository.ts
│ ├── category.repository.ts
│ ├── comment.repository.ts
│ └── serviceview.repository.ts
├── transaction/ # Transaction persistence
│ └── transaction.repository.ts
├── user/ # User persistence
│ ├── user.repository.ts
│ ├── identity.repository.ts
│ ├── userrole.repository.ts
│ └── portfolio.repository.ts
├── storage/ # File storage
│ ├── local.ts
│ └── upload.repository.ts
└── shared/ # Shared infrastructure
├── db.ts
├── cache/
│ └── redis.ts
├── mongo/
│ └── models/
├── sql/
│ ├── models/
│ └── migrations/
└── repository/
└── sql.ts
Best Practices
✅ Do
- Implement domain interfaces – Adapters fulfill domain contracts
- Map data carefully –
loads()anddumps()ensure clean boundaries - Handle errors – Convert infrastructure errors to domain exceptions
- Use transactions – Ensure data consistency
- Log infrastructure errors – Help with debugging
❌ Don't
- Don't expose ORM models – Always return domain entities
- Don't put business logic in adapters – Keep it in domain/application layers
- Don't couple to specific database – Use abstractions where possible
- Don't skip error handling – Infrastructure can fail
- Don't hardcode credentials – Use environment variables
Summary
The Adapters Layer:
- Implements infrastructure required by domain and application layers
- Manages persistence using ORMs and database drivers
- Integrates external services (payments, storage, notifications)
- Handles data mapping between domain entities and database models
- Provides concrete implementations of abstract interfaces
This layer keeps infrastructure concerns separate from business logic, making the system flexible and maintainable.