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 notGreat! 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.tsDomain 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:
Environment Configuration: Add your database URL to the
.envfile (this is the secure way to handle sensitive data):// .env DB_FILE_NAME=file:local.dbPackage.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.tsfile 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});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.jsonWhat’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!