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 -> UUID                       // Will act as userId
    name -> string                   // name of user
    email -> string                  // email of user
    createdAt -> Date                // user creation date-time
    updatedAt -> Date                // user updation date-time

Project -
    id -> UUID                       // Will act as projectId          
    title -> string                  // title of project
    color -> string                  // color associated with that project, will act as identifier
    createdBy -> UUID                // Creator of project
    createdAt -> Date                // project creation date-time
    updatedAt -> Date                // project updation date-time

Task -
    id -> UUID                       // Will act as taskId
    title -> string                  // title of task
    description -> string            // description of task
    projectId -> UUID                // projectId under which this task is created
    createdBy -> UUID                // Owner/Creator of task
    dueDate -> Date?                 // Optional property, due date by which this task should be completed
    priority -> 1 | 2 | 3 | 4        // Priority of the task, lower number = higher priority
    isCompleted -> boolean           // is given task completed or not
    createdAt -> Date                // task creation date-time
    updatedAt -> Date                // task updation date-time

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
  • Supabase - 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

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

# Install initial packages
bun add drizzle-orm postgres dotenv zod
bun add -D drizzle-kit tsx

Domain Entity Implementation

Now we’ll define our domain entities:

 1// users.domain.ts
 2
 3import { pgTable, varchar, uuid, timestamp } from "drizzle-orm/pg-core";
 4import z from "zod";
 5
 6// Domain Model
 7
 8export const UserModelSchema = z.object({
 9  id: z.string().uuid(),
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  createdAt: z.date(),
19  updatedAt: z.date().optional(),
20});
21
22export type UserModel = z.infer<typeof UserModelSchema>;
23
24// Database Table Definition
25
26export const UsersTable = pgTable("users", {
27  id: uuid("id").primaryKey().defaultRandom(),
28  name: varchar("name", { length: 255 }).notNull(),
29  email: varchar("email", { length: 255 }).notNull().unique(),
30  createdAt: timestamp("created_at").defaultNow().notNull(),
31  updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
32    () => new Date()
33  ),
34});
 1// projects.domain.ts
 2
 3import { z } from "zod";
 4import { pgTable, uuid, varchar, timestamp } from "drizzle-orm/pg-core";
 5import { UsersTable } from "./users.domain";
 6
 7// Domain Models schema
 8
 9export const ProjectModelSchema = z.object({
10  id: z.string().uuid(),
11  title: z.string().min(1).max(255),
12  createdBy: z.string().uuid(),
13  createdAt: z.date(),
14  updatedAt: z.date().optional(),
15});
16
17export type ProjectModel = z.infer<typeof ProjectModelSchema>;
18
19// Database Table Definition - Drizzle ORM
20
21export const ProjectsTable = pgTable("projects", {
22  id: uuid("id").primaryKey().defaultRandom(),
23  title: varchar("title", { length: 255 }).notNull(),
24  createdBy: uuid("created_by")
25    .references(() => UsersTable.id)
26    .notNull(),
27  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
28  updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
29    () => new Date()
30  ),
31});
 1// tasks.domain.ts
 2
 3import { z } from "zod";
 4import { pgTable, uuid, varchar, timestamp, pgEnum, boolean } from "drizzle-orm/pg-core";
 5import { UsersTable } from "./users.domain";
 6import { ProjectsTable } from "./projects.domain";
 7
 8// Domain Models schema
 9
10const TasksPriority = ["1", "2", "3", "4"] as const;
11
12export const TaskModelSchema = z.object({
13  id: z.string().uuid(),
14  title: z
15    .string()
16    .min(1, "Title is required")
17    .max(255, "Title must be less than 255 characters"),
18  description: z
19    .string()
20    .min(1, "Description is required")
21    .max(255, "Description must be less than 255 characters")
22    .optional(),
23  projectId: z.string().uuid(),
24  createdBy: z.string().uuid(),
25  dueDate: z.date().optional(),
26  priority: z.enum(TasksPriority),
27  isCompleted: z.boolean(),
28  createdAt: z.date(),
29  updatedAt: z.date().optional(),
30});
31
32export type TaskModel = z.infer<typeof TaskModelSchema>;
33
34// Database Table Definition - Drizzle ORM
35
36export const TasksPriorityEnum = pgEnum("tasks_priority", ["1", "2", "3", "4"]);
37
38export const TasksTable = pgTable("tasks", {
39  id: uuid("id").primaryKey().defaultRandom(),
40  title: varchar("title", { length: 255 }).notNull(),
41  description: varchar("description", { length: 255 }),
42  projectId: uuid("project_id")
43    .references(() => ProjectsTable.id)
44    .notNull(),
45  createdBy: uuid("created_by")
46    .references(() => UsersTable.id)
47    .notNull(),
48  dueDate: timestamp("due_date", { withTimezone: true }),
49  priority: TasksPriorityEnum("priority").notNull(),
50  isCompleted: boolean("is_completed").default(false).notNull(),
51  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
52  updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
53    () => new Date()
54  ),
55});

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
    
    DATABASE_URL=<SUPABASE_URL>
  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 Supabase
     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: 'postgresql',
    17    dbCredentials: {
    18        // Database connection string
    19        url: process.env.DATABASE_URL as string,
    20    },
    21});
  4. Database Migration: Run these two commands in your terminal:

    bun run db:generate
    bun run db:migrate

Voila! You should now see the tables created in your Supabase project under Database → Tables.

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!