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:
Environment Configuration: Add your database URL to the
.env
file (this is the secure way to handle sensitive data):// .env DATABASE_URL=<SUPABASE_URL>
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" } }
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});
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!