Skip to content

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 mappingloads() 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 interfaceFileSystem 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 carefullyloads() and dumps() 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.