Anatomy of Hexagonal Architecture - Part 2

As mentioned in the previous blog post, we’ll now start with the actual implementation.

Just as the construction of a home starts with its foundation, we’re going to begin by designing the base of our application - the Domain/Core layer by defining entities for our app.

To understand the entities of our app, we first need to understand the features of our application and what it does.

Todoist has many capabilities, but for learning purposes, we’ll focus on just a few core features:

  • A User owns many Projects
  • A Project has many Tasks
  • A User can create many Tasks under a Project

From these requirements, we can define the entities of our app:

  • User
  • Project
  • Task

Let’s define the properties of these entities:

User -
    id -> integer                    // Will act as userId
    name -> string                   // name of user
    email -> string                  // email of user

Project -
    id -> integer                    // Will act as projectId          
    title -> string                  // title of project
    createdBy -> integer             // Creator of project

Task -
    id -> integer                    // Will act as taskId
    title -> string                  // title of task
    description -> string            // description of task
    projectId -> integer             // projectId under which this task is created
    createdBy -> integer             // Owner/Creator of task
    isCompleted -> boolean           // is given task completed or not

Great! Our entity definition is complete. Now we need to translate this logic into readable code.

Before we start coding, let’s prepare our arsenal - tools, project initialization, folder structure, etc.

Tools

  • Bun - will be our runtime
  • SQLite - will be our database
  • Drizzle - will be our ORM (Object Relational Mapping) tool
  • Zod - will be our validator

Project Setup

Run the following commands to initialize the project and create the initial folder structure:

# Create a blank project with bun having name mytodoist
bun init mytodoist
cd mytodoist

# Install initial packages
bun add drizzle-orm @libsql/client dotenv zod
bun add -D drizzle-kit tsx

# create required files/folders
/mytodoist> touch .env
/mytodoist> mkdir adapters cmd core infrastructure migrations
/mytodoist> mkdir core/domain
/mytodoist/core/domain> cd core/domain
/mytodoist/core/domain>  touch users.domain.ts projects.domain.ts tasks.domain.ts

Domain Entity Implementation

Now we’ll define our domain entities:

 1// users.domain.ts
 2
 3import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
 4import z from "zod";
 5
 6// Domain Model
 7
 8export const UserModelSchema = z.object({
 9  id: z.int(),
10  name: z
11    .string()
12    .min(1, "Name is required")
13    .max(255, "Name must be less than 255 characters"),
14  email: z
15    .string()
16    .email("Invalid email format")
17    .max(255, "Email must be less than 255 characters"),
18});
19
20export type UserModel = z.infer<typeof UserModelSchema>;
21
22// Database Table Definition
23
24export const UsersTable = sqliteTable("users", {
25  id: integer("id").primaryKey(),
26  name: text("name", { length: 255 }).notNull(),
27  email: text("email", { length: 255 }).notNull().unique(),
28});
 1// projects.domain.ts
 2
 3import { z } from "zod";
 4import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
 5import { UsersTable } from "./users.domain";
 6
 7// Domain Models schema
 8
 9export const ProjectModelSchema = z.object({
10  id: z.int(),
11  title: z
12    .string()
13    .min(1, "Project Title is required")
14    .max(255, "Project Title must be less than 255 characters"),
15  createdBy: z.int(),
16});
17
18export type ProjectModel = z.infer<typeof ProjectModelSchema>;
19
20// Database Table Definition - Drizzle ORM
21
22export const ProjectsTable = sqliteTable("projects", {
23  id: integer("id").primaryKey(),
24  title: text("title", { length: 255 }).notNull(),
25  createdBy: integer("created_by")
26    .references(() => UsersTable.id)
27    .notNull(),
28});
 1// tasks.domain.ts
 2
 3import { z } from "zod";
 4import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
 5import { UsersTable } from "./users.domain";
 6import { ProjectsTable } from "./projects.domain";
 7import { sql } from "drizzle-orm";
 8
 9// Domain Models schema
10
11export const TaskModelSchema = z.object({
12  id: z.int(),
13  name: z
14    .string()
15    .min(1, "Task Name is required")
16    .max(255, "Task Name must be less than 255 characters"),
17  description: z
18    .string()
19    .min(1, "Description is required")
20    .max(255, "Description must be less than 255 characters")
21    .optional(),
22  projectId: z.int(),
23  createdBy: z.int(),
24  isCompleted: z.boolean(),
25});
26
27export type TaskModel = z.infer<typeof TaskModelSchema>;
28
29// Database Table Definition - Drizzle ORM
30
31export const TasksTable = sqliteTable("tasks", {
32  id: integer("id").primaryKey(),
33  name: text("name", { length: 255 }).notNull(),
34  description: text("description", { length: 255 }),
35  projectId: integer("project_id")
36    .references(() => ProjectsTable.id)
37    .notNull(),
38  createdBy: integer("created_by")
39    .references(() => UsersTable.id)
40    .notNull(),
41  isCompleted: integer("is_completed", { mode: "boolean" }).default(
42    sql`(abs(0))`
43  ),
44});

Configuration Setup

Now, follow these steps to complete the setup:

  1. Environment Configuration: Add your database URL to the .env file (this is the secure way to handle sensitive data):

    // .env
    
    DB_FILE_NAME=file:local.db
  2. Package.json Scripts: Add essential commands to your package.json:

    // package.json
    
    {
        "scripts": {
            "start": "bun run index.ts",
            "db:generate": "drizzle-kit generate",
            "db:migrate": "drizzle-kit migrate"
        }
    }
  3. Drizzle Configuration: Create a drizzle.config.ts file in the root folder and add the following code:

     1// drizzle.config.ts
     2
     3import "dotenv/config";
     4import { defineConfig } from "drizzle-kit";
     5
     6export default defineConfig({
     7  // Drizzle will pick up DB schemas from here
     8  // and sync with Local SQLite DB
     9  schema: "./core/domain/*.domain.ts",
    10
    11  // All future incremental DB changes will be stored here
    12  // representing the current state/schema of tables
    13  out: "./migrations",
    14
    15  // Database type
    16  dialect: "sqlite",
    17  dbCredentials: {
    18    // Database connection string
    19    url: process.env.DB_FILE_NAME!,
    20  },
    21});
  4. Database Migration: Run these two commands in your terminal:

    bun run db:generate
    bun run db:migrate

Voila! Tables are created in your local SQLite database.

Understanding Our Domain Layer

This completes our Domain layer implementation. Before ending this blog post, let me also explain our folder structure.

As mentioned in the previous blog, the domain is part of our core. We’ve placed it in the core folder, and there are other folders we created that will make more sense as we progress further in this series:

├── adapters
├── cmd
├── core                        // Core of our application
│   └── domain                  // Domain layer
│       ├── projects.domain.ts
│       ├── tasks.domain.ts
│       └── users.domain.ts
├── drizzle.config.ts
├── index.ts
├── infrastructure
├── migrations
├── package.json

What’s Coming Next

In the next part of this series, we’ll be moving outside of the core and implementing ports, services, and repositories.

Stay tuned for hands on coding where theory meets reality!