How To Build An API With Node, Without Additional Framework (SQL Included)
July 25th, 2025 | 20 mins readPublished on dev.to and medium.com
This is not just a post... That's a how-to guide!
Introduction
Every technical project has its file structure. Whether it's a traditional MVC structure, organization by components or pages, by features, or even hexagonal architecture ("Ports/Adapters" for the insiders 😉), many possibilities are conceivable before even starting to code.
"We structure our project like the previous one. Copy/Paste: it works!"
Often considered secondary to the benefit of an application that executes quickly (and without error), the chosen architecture nevertheless has a crucial place in the project, to ensure the sustainability of current and future developments...
Like project management strategies that have evolved over time (I'm thinking particularly of Agility and its principles), project organizations have become democratized. The classic MVC evolved to MV-VM (Model-View/View-Model) to give way to MVW ("W" for "Whatever")... Although, for the past few years, we've been hearing mostly about "Clean" architectures, particularly with the "Ports/Adapters" and "Onion" models (organization by concentric layers).
Much more than a simple "arrangement" of components, classes, functions or modules, in the associated folder, it's about making the business part of your application independent of technical concepts (the data access layer, HTTP configuration, application routing, etc.).
NB: I really advise you to ask yourself the architecture question, if only to have a horizon on the file structure to use during your developments, in order to avoid a major refactoring of your project at 90% completion 😅
It's thanks to development strategies such as TDD (Test Driven Development), DDD (Domain Driven Development) or BDD (Behavior Driven Development) that SOLID principles emerged. Indeed, these methods highlighted the limitations of traditional approaches and favored the adoption of principles such as SOLID, which are now the foundation of modern architectures.
If I mention all this in the preamble, it's to justify the technical orientation of my project. In the rest of this post this guide, we're going to talk about how to "build an API with Node, without additional frameworks", but in a SOLID way (and also with some OOP principles).
NB: Although I try to apply SOLID principles as best as possible, I sometimes find myself thinking too long about achieving these goals: single responsibility of components, reusability of properties, inversion of control (and dependency injection), etc... In these cases, I remind myself that "perfect is the enemy of good", then... I
commit
! Maybe I'll come back to my development later (or maybe not).
Hello World 👋
Let's get to the heart of the matter, namely, how to expose a REST API for user management from Node, without additional libraries.
I emphasize this aspect because it's rather simple to create a Backend application with Express or Fastify. Moreover, these libraries are quite efficient for manipulating HTTP requests and responses, often by simplifying JSON serialization, HTTP status management, or REST routing.
Below, we'll discover (or rediscover) that it's possible to do this solely from the node:http
module.
import { createServer, IncomingMessage, Server, ServerResponse } from 'node:http';
type ApplicationRequest = IncomingMessage;
type ApplicationResponse = ServerResponse<IncomingMessage> & {
req: IncomingMessage;
};
interface ApplicationRouter {
listener: (request: ApplicationRequest, response: ApplicationResponse) => Promise<void>;
}
class HttpProvider {
constructor(private server: Server) {}
static handleListeners = (...routers: ApplicationRouter[]) => {
return async (request: ApplicationRequest, response: ApplicationResponse) => {
for (const router of routers) {
await router.listener(request, response);
}
try {
response.setHeader('Content-Type', 'application/json');
response.statusCode = 400;
response.end(JSON.stringify({ message: "Cannot Get '/'" }));
} catch {
console.log('Ignored Default Behavior');
}
};
};
start(port: number) {
this.server.listen(port, () => {
console.log(`Server Is Running On Port ${port}`);
});
}
stop() {
this.server.close(() => {
console.log('Server Is Shutting Down...');
});
}
}
const requestHandler = HttpProvider.handleListeners();
const server = createServer(requestHandler);
const app = new HttpProvider(server);
app.start(8080);
Here's a first version of the class responsible for starting the application. Its scope is limited to managing the service (start()
/ stop()
) as well as listening to future incoming requests provided by the routing classes. Moreover, with the handleListeners()
function, I anticipate the upcoming routing, particularly for the implementation of the user management CRUD.
Centralizing the Router
s from this function allows chaining several route handlers in a modular way, while keeping the response logic in a single entry point. By default, the function will terminate its execution with a response having a status code equivalent to Bad Request
, and the following message: Cannot Get "/"
. This mechanism is reminiscent of the behavior of a "dispatcher" middleware, where each route handler is executed in turn until a response is sent.
import { ApplicationRequest } from '../providers/ApplicationRequest';
import { ApplicationResponse } from '../providers/ApplicationResponse';
import { ApplicationRouter } from '../routers/ApplicationRouter';
class HelloController {
greeting(_request: ApplicationRequest, response: ApplicationResponse) {
response.setHeader('Content-Type', 'application/json');
response.statusCode = 200;
response.end(JSON.stringify({ message: 'Hi!' }));
return;
}
}
class HelloRouter implements ApplicationRouter {
static PATHNAME = '/api/hello';
constructor(private helloController: HelloController) {}
async listener(request: ApplicationRequest, response: ApplicationResponse) {
const baseUrl = `http://${request.headers.host}`;
const url = new URL(request.url, baseUrl);
const strictPathName = new RegExp(`^${HelloRouter.PATHNAME}$`);
if (request.method === 'GET' && strictPathName.test(url.pathname)) {
return this.helloController.greeting(request, response);
}
}
}
Above, two new classes complement the application initialization. These are respectively a controller as well as the routing associated with the /api/hello
endpoint. HelloRouter
encapsulates HelloController
(by dependency injection), then calls the controller's greeting
method upon receiving a GET request.
import { createServer } from 'node:http';
import HelloController from './infrastructure/controllers/HelloController';
import HttpProvider from './infrastructure/providers/HttpProvider';
import HelloRouter from './infrastructure/routes/HelloRouter';
const helloController = new HelloController();
const helloRouter = new HelloRouter(helloController);
const requestHandler = HttpProvider.handleListeners(helloRouter);
const server = createServer(requestHandler);
const app = new HttpProvider(server);
app.start(8080);
By instantiating the controller and defining the routing, we can provide a context to the requestHandler
function which will then be processed by the server. Thus, upon receiving an HTTP request, the Node server will check the request information (the path and the REST method invoked) before landing in the default use case.
This design allows me to divide finely by functionality, rather than having global routing with multiple responsibilities...
Before finishing with this first step, let's set up a "Clean" architecture (according to the model popularized by Robert C. Martin) by structuring our application as above. This way, classes are separated from each other in the manner of one file ===
one responsibility.
src/
├── core/
├── infrastructure/
│ ├── controllers/
│ │ └── HelloController.ts
│ ├── providers/
│ │ ├── ApplicationRequest.ts
│ │ ├── ApplicationResponse.ts
│ │ └── HttpProvider.ts
│ └── routes/
│ ├── ApplicationRouter.ts
│ └── HelloRouter.ts
└── index.ts
In this exercise, the "IoC" container is represented by the src/index.ts
file. As the application's entry point, it's responsible for instantiating the different components and applying inversion of control.
The Core 🎯
This is where everything really begins / where your application comes to life!
The Core groups together the classes and algorithms related to business logic. Generally, whether you've opted for hexagonal architecture, or concentric layers, or simply derived from the other two models, you'll find:
- Domain business entities (or POJO - Plain Old JavaScript Object, for Java nostalgics);
- Services associated with these classes (i.e., the algorithms for creation, data retrieval, or updating);
- Interfaces that define how external services (data sources, controllers, etc.) should behave so that the Core can interact with them (without explicitly knowing them).
In this second part, we get to the serious stuff by initializing the first elements of the user management CRUD:
- The
User
business class; The serviceThe use cases** for user creation and retrieval;- The interfaces for inversion of control.
** About use cases...
Some implementations (like hexagonal architecture) centralize all the business logic of a domain in a single service. You'll find the creation, reading, updating, and deletion methods in the same file, exposed by an interface.
Other architectures opt for a finer separation: one file per use case, having a single method (execute()
or invoke()
). This breakdown is more verbose, but often simpler to test, maintain, and evolve.
class User {
readonly id: string;
readonly firstName: string;
readonly lastName: string;
readonly email: string;
constructor(id: string, firstName: string, lastName: string, email: string) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
}
toString(): string {
return JSON.stringify(this);
}
}
export default User;
We start with the domain entity. Here, we model a simple business object representing a user, with 4 essential fields and a constructor dedicated to their initialization. The use of the readonly
keyword (specific to TypeScript) ensures the immutability of these properties once the instance is created, thus strengthening the object's integrity.
NB: In the absence of business logic in the accessors, we voluntarily choose to do without
Getters/Setters
to lighten the code and focus on its readability.
import { NewUser } from '../interfaces/NewUser';
import { UserRepository } from '../interfaces/UserRepository';
export class CreateUser {
constructor(private userRepository: UserRepository) {}
async execute(newUser: NewUser): Promise<User> {
const id = Math.random().toString(32).substring(2, 10);
try {
const user = new User(id, newUser.firstName, newUser.lastName, newUser.email);
const createdUser = await this.userRepository.create(user);
return createdUser;
} catch (err) {
throw new Error('Internal Server Error');
}
}
}
export class GetAllUsers {
constructor(private userRepository: UserRepository) {}
async execute(): Promise<User[]> {
return this.userRepository.findAll();
}
}
export class GetOneUser {
constructor(private userRepository: UserRepository) {}
async execute(id: string): Promise<User> {
const user = await this.userRepository.findById(id);
if (user === null) {
throw new Error('User Not Found');
}
return user;
}
}
Above, you'll find the use cases (not to mention functional services) for creating and retrieving one or more users.
NB: Here, for the post's benefits, this is a single code portion; but for optimal organization, it would be preferable to have one exported class per file (prefer
export default
rather thanexport
).
You'll have noticed that use cases don't act directly on data, and that's normal since that's the objective of a "Clean" model. Instead, they know how the application service behaves (via its UserRepository
interface) without ever having access to a concrete implementation. It's up to the "IoC" container (cf. src/index.ts
) to inject dependencies, allowing the interconnection of technical infrastructures and the Core.
import User from '../domain/User';
// Input DTO
export interface NewUser {
firstName: string;
lastName: string;
email: string;
}
export interface UserRepository {
findAll: () => Promise<User[]>;
findById: (id: string) => Promise<User | null>;
create: (user: User) => Promise<User>;
// TODO: update: (user: User) => Promise<User>;
// TODO: delete: (id: string) => Promise<number>;
}
These first elements*** (namely the domain business entity, use cases, and interface) constitute a minimal base for implementing the Core, it remains to know how the infrastructure works...
*** About the Core content...
The Core can include more elements such as:
- Business exceptions, to handle errors specific to the use case (
UserNotFoundError
,UserConflictError
, etc.), rather than a generic technical error; - "Object-Values" for finer management of domain business entity fields, particularly regarding their respective validation.
NB: I invite you to consult the project sources on GitHub to learn more.
At the end of this second part, here's the structure after implementing the Core:
src/
├── core/
│ ├── domain/
│ │ └── User.ts
│ ├── interfaces/
│ │ ├── NewUser.ts
│ │ └── UserRepositoryPort.ts
│ └── use-cases/
│ ├── CreateUser.ts
│ ├── GetAllUsers.ts
│ └── GetOneUser.ts
├── infrastructure/
└── index.ts
Infrastructure ⚙️
Point of contact between business logic and the external world.
To be able to expose a user management API, we'll need a controller, the routing associated with the endpoints, as well as the implementation responsible for reading and writing data.
Let's take things in order with the Router
. I propose a class that implements the ApplicationRouter
interface and has a single method (listener
) taking the HTTP request and response as parameters. This same function will then be called by the previous HttpProvider
.
import UserController from '../controllers/UserController';
import { ApplicationRequest } from '../providers/ApplicationRequest';
import { ApplicationResponse } from '../providers/ApplicationResponse';
import { ApplicationRouter } from './ApplicationRouter';
class UserRouter implements ApplicationRouter {
static PATHNAME = '/api/users';
constructor(private userController: UserController) {}
async listener(request: ApplicationRequest, response: ApplicationResponse) {
const baseUrl = `http://${request.headers.host}`;
const url = new URL(request.url as string, baseUrl);
const strictPathName = new RegExp(`^${UserRouter.PATHNAME}$`);
const pathNameWithId = new RegExp(`^${UserRouter.PATHNAME}/[^/]+$`);
if (request.method === 'GET') {
if (strictPathName.test(url.pathname)) {
return this.userController.getAll(request, response);
}
if (pathNameWithId.test(url.pathname)) {
return this.userController.getOne(request, response);
}
}
if (request.method === 'POST' && strictPathName.test(url.pathname)) {
return this.userController.createOne(request, response);
}
}
}
export default UserRouter;
The Router
acts as a "dispatcher": it isolates the routing logic by mapping endpoints to controller methods, without handling business logic.
From this component, we can see that the application will expose the /api/users
endpoint both in GET and POST. Depending on the path (presence or absence of the identifier in the URL) and the REST verb used, the application will execute the methods associated with the controller. The controller will then be responsible for writing the HTTP response.
In summary, here's the routing for user management:
/api/users
in GET/api/users/:id
in GET/api/users
in POST
import { NewUser } from '../../core/interfaces/UserInput';
import CreateUser from '../../core/use-cases/CreateUser';
import GetAllUsers from '../../core/use-cases/GetAllUsers';
import GetOneUser from '../../core/use-cases/GetOneUser';
import { ApplicationRequest } from '../providers/ApplicationRequest';
import { ApplicationResponse } from '../providers/ApplicationResponse';
import UserRouter from '../routes/UserRouter';
import { bodyParser } from '../utils';
class UserController {
constructor(
private createUser: CreateUser,
private getAllUsers: GetAllUsers,
private getOneUser: GetOneUser
) {}
async getAll(_request: ApplicationRequest, response: ApplicationResponse) {
const users = await this.getAllUsers.execute();
response.setHeader('Content-Type', 'application/json');
response.statusCode = 200;
response.end(JSON.stringify(users));
return;
}
async getOne(request: ApplicationRequest, response: ApplicationResponse) {
const baseUrl = `http://${request.headers.host}`;
const url = new URL(request.url as string, baseUrl);
const pathNameWithId = new RegExp(`^${UserRouter.PATHNAME}/([^/]+)$`);
const matches = url.pathname.match(pathNameWithId);
if (!matches) {
response.setHeader('Content-Type', 'application/json');
response.statusCode = 400;
response.end(JSON.stringify({ message: `Cannot Get ${UserRouter.PATHNAME}` }));
return;
}
const [_input, id] = matches;
try {
const user = await this.getOneUser.execute(id);
response.setHeader('Content-Type', 'application/json');
response.statusCode = 200;
response.end(JSON.stringify(user));
return;
} catch (err) {
response.setHeader('Content-Type', 'application/json');
response.statusCode = 404;
response.end(JSON.stringify({ message: err.message }));
return;
}
}
async createOne(request: ApplicationRequest, response: ApplicationResponse) {
const body = await bodyParser<NewUser>(request);
try {
const user = await this.createUser.execute(body);
response.setHeader('Content-Type', 'application/json');
response.statusCode = 201;
response.end(JSON.stringify(user));
return;
} catch (err) {
response.setHeader('Content-Type', 'application/json');
response.statusCode = 500;
response.end(JSON.stringify({ message: err.message }));
return;
}
}
}
export default UserController;
Let's now discuss the controller's role. The objective of UserController
is to invoke the use case and describe the HTTP response. By injecting the CreateUser
, GetUser
, and GetAllUsers
dependencies, the controller can retrieve data, then enrich the HTTP response body.
Since we're working with Node from scratch without additional frameworks, we'll need to format the data as character strings, and configure the HTTP response headers to interpret the returned value in JSON format.
NB: To retrieve the HTTP request body, I use a custom
bodyParser
function (available on GitHub) as well as an input DTO:NewUser
.
import { createServer } from 'node:http';
import CreateUser from './core/use-cases/CreateUser';
import GetAllUsers from './core/use-cases/GetAllUsers';
import GetOneUser from './core/use-cases/GetOneUser';
import HelloController from './infrastructure/controllers/HelloController';
import HttpProvider from './infrastructure/providers/HttpProvider';
import HelloRouter from './infrastructure/routes/HelloRouter';
// TODO: const userRepository = new UserRepositoryAdapter();
const getAllUsersUseCase = new GetAllUsers(userRepository);
const getOneUserUseCase = new GetOneUser(userRepository);
const createUserUseCase = new CreateUser(userRepository);
const userController = new UserController(getAllUsersUseCase, getOneUserUseCase, createUserUseCase);
const userRouter = new UserRouter(userController);
const requestHandler = HttpProvider.handleListeners(userRouter);
const server = createServer(requestHandler);
const app = new HttpProvider(server);
app.start(8080);
The application is starting to take shape... But wouldn't the part responsible for data access be missing!? 🤔
Data Access Layer 📊
Let's talk data properly! Before discussing the possibility of persisting data in a database (directly from Node), here's a first implementation of UserRepository
with data stored in memory.
import User from '../../core/domain/User';
import UserRepository from '../../core/interfaces/UserRepositoryPort';
class UserRepositoryAdapter implements UserRepository {
private users: User[] = [];
async findAll(): Promise<User[]> {
return this.users;
}
async findById(id: string): Promise<User | null> {
return this.users.find(user => user.id === id) ?? null;
}
async create(user: User): Promise<User> {
this.users = [...this.users, user];
return user;
}
// TODO: async update(user: User): Promise<User> {}
// TODO: async delete(id: string): Promise<number> {}
}
export default UserRepositoryAdapter;
This is the last piece missing from the puzzle 🧩 By instantiating UserRepositoryAdapter
from the project's entry point, we should have a first version of an operational user management CRUD (although limited) with in-memory access (and without additional frameworks).
NB: As a reminder, the controller calls a use case (
CreateUser
,GetAllUsers
, orGetOneUser
), which relies on theUserRepository
interface, which is concretely implemented here byUserRepositoryAdapter
.
TypeScript requires a compilation step before execution. Although Node "is starting" to offer native support for .ts
, I opted here for ESBuild (an ultra-efficient bundler), to generate a JavaScript file executable simply with Node: esbuild src/index.ts --bundle --format=esm --outfile=dist/index.js --platform=node --target=node22.14 && node dist/index.js
SQLite From Scratch 🪶
Let's not settle for simple in-memory access, which is reset with each application restart... Let's persist our data with SQL.
Node version 22.5.0 introduces an API to communicate with an embedded SQL database (cf. SQLite). By introducing an abstraction to control the execution of SQL queries, we can manipulate user data with simplicity and security.
NB: Note that the API is subject to change, Node is quite explicit about this, with the following message when launching the application:
ExperimentalWarning: SQLite is an experimental feature and might change at any time
import { DatabaseSync, SupportedValueType } from 'node:sqlite';
class SqlManager {
private database;
constructor(database: DatabaseSync) {
this.database = database;
}
execute(sql: string) {
this.database.exec(sql);
}
query<T>(sql: string) {
const statement = this.database.prepare(sql);
return (...params: SupportedValueType[]): T => statement.get(...params) as T;
}
queryAll<T>(sql: string) {
const statement = this.database.prepare(sql);
return (...params: SupportedValueType[]): Array<T> => statement.all(...params) as Array<T>;
}
mutate(sql: string) {
const statement = this.database.prepare(sql);
return (...params: SupportedValueType[]): { changes: bigint } => {
const result = statement.run(...params);
return { changes: result.changes };
};
}
}
export default SqlManager;
Here's the SQL abstraction I propose. Node's API for SQLite offers two ways to execute SQL queries:
- Either via
exec()
, to simply execute a query in the database (for creating a table, for example); - Or via
prepare()
, to create a parameterized statement of a query (ideal for avoiding SQL injection) and execute it in a second step.
This second possibility is really interesting (and justifies my design ;). Whether it's query()
, queryAll()
, or mutate()
, each of these functions remembers the SQL query (and its parameters) to then react in three distinct ways:
get()
to execute the query and retrieve a value;all()
to execute the query and retrieve a list of values;run()
to retrieve information after executing the query (notably the number of impacted entities);
Each of these operations executes (with or without parameters) one and only one time. This means that to retrieve updated values (again), you'll need to mount a parameterized statement. Hence the interest of the SqlManager
abstraction!
Now that we understand how SQLite works for Node, let's add a concrete implementation for persisting user data in the database:
import { DatabaseSync } from 'node:sqlite';
import SqlManager from '../providers/SqlManager';
export const CREATE_TABLE_USERS = `
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(8) PRIMARY KEY,
first_name VARCHAR(32) NOT NULL,
last_name VARCHAR(32) NOT NULL,
email VARCHAR(128) NOT NULL UNIQUE
);
`;
export const CREATE_USER = `
INSERT INTO users (id, first_name, last_name, email)
VALUES (?, ?, ?, ?)
`;
export const SELECT_ALL_USERS = `
SELECT * FROM users
`;
export const SELECT_USER = `
SELECT * FROM users WHERE id = ?
`;
export interface UserEntity {
id: string;
first_name: string;
last_name: string;
email: string;
}
class UserManagement extends SqlManager {
constructor(database: DatabaseSync) {
super(database);
this.createTable();
}
createTable() {
this.execute(CREATE_TABLE_USERS);
}
createUser(id: string, firstName: string, lastName: string, email: string) {
const statement = this.mutate(CREATE_USER);
const { changes } = statement(id, firstName, lastName, email);
console.log('createUser::changes', changes);
}
selectAllUsers() {
const statement = this.queryAll<UserEntity>(SELECT_ALL_USERS);
return statement();
}
selectUserById(id: string) {
const statement = this.query<UserEntity>(SELECT_USER);
return statement(id);
}
}
export default UserManagement;
NB: Same remark as for use cases, you'll find here the implementation of the data persistence layer, as well as the constants representing the SQL queries; for better organization, it would be preferable to break down the code more finely...
Finally, let's modify the UserRepository
class to point to the database data, rather than a strategy of storing data in memory:
import User from '../../core/domain/User';
import { UserRepository } from '../../core/interfaces/UserRepository';
import { UserEntity } from '../persistence/UserEntity';
import UserManagement from '../persistence/UserManagement';
const toUser = (entity: UserEntity): User =>
new User(entity.id, entity['first_name'], entity['last_name'], entity.email);
class UserRepositoryAdapter implements UserRepository {
constructor(private userPersistance: UserManagement) {}
async findAll(): Promise<User[]> {
const entities = this.userPersistance.selectAllUsers();
return entities.map(toUser);
}
async findById(id: string): Promise<User | null> {
const entity = this.userPersistance.selectUserById(id);
if (!entity) return null;
return toUser(entity);
}
async create(user: User): Promise<User> {
this.userPersistance.createUser(user.getId(), user.getFirstName(), user.getLastName(), user.getEmail());
return user;
}
// TODO: async update(user: User): Promise<User> {}
// TODO: async delete(id: string): Promise<number> {}
}
export default UserRepositoryAdapter;
There we are! Let's finish the exercise by updating our entry point and launching the application.
import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import CreateUser from './core/use-cases/CreateUser';
import GetAllUsers from './core/use-cases/GetAllUsers';
import GetOneUser from './core/use-cases/GetOneUser';
import UserRepositoryAdapter from './infrastructure/adapters/UserRepositoryAdapter';
import UserController from './infrastructure/controllers/UserController';
import UserManagement from './infrastructure/persistence/UserManagement';
import HttpProvider from './infrastructure/providers/HttpProvider';
import UserRouter from './infrastructure/routes/UserRouter';
const database = new DatabaseSync('./foobar.db'); // ':memory:'
const userManagement = new UserManagement(database);
const userRepository = new UserRepositoryAdapter(userManagement);
const getAllUsersUseCase = new GetAllUsers(userRepository);
const getOneUserUseCase = new GetOneUser(userRepository);
const createUserUseCase = new CreateUser(userRepository);
const userController = new UserController(getAllUsersUseCase, getOneUserUseCase, createUserUseCase);
const userRouter = new UserRouter(userController);
const requestHandler = HttpProvider.handleListeners(userRouter);
const server = createServer(requestHandler);
const app = new HttpProvider(server);
app.start(8080);
Conclusion
We've just laid the foundations of a modular and properly structured application, relying on the principles of "Clean" architecture. If you've followed all the steps in this guide, you should have a structure equivalent to this:
src/
├── core/
│ ├── domain/
│ │ └── User.ts
│ ├── interfaces/
│ │ ├── NewUser.ts
│ │ └── UserRepositoryPort.ts
│ └── use-cases/
│ ├── CreateUser.ts
│ ├── GetAllUsers.ts
│ └── GetOneUser.ts
├── infrastructure/
│ ├── adapters/
│ │ └── UserRepositoryAdapter.ts
│ ├── controllers/
│ │ └── UserController.ts
│ ├── persistence/
│ │ ├── UserEntity.ts
│ │ ├── UserManagement.ts
│ │ └── UserQueries.ts
│ ├── providers/
│ │ ├── ApplicationRequest.ts
│ │ ├── ApplicationResponse.ts
│ │ ├── HttpProvider.ts
│ │ └── SqlManager.ts
│ └── routes/
│ ├── ApplicationRouter.ts
│ └── UserRouter.ts
└── index.ts
We could push the "Clean" architecture ideology even further by opting for a more "strict" organization with three distinct parts (application
/ domain
/ infrastructure
), but this structure already constitutes a robust foundation that conforms to SOLID principles and is efficient in terms of readability, testability, and scalability.
This breakdown allows you to adapt or replace a technical component without impacting the other layers. Moreover, the progressive addition of persistence via SQLite proves that it's possible to integrate a technology without ever disturbing the application's Core; this is the true strength of this architecture!
Beyond the architecture, it's particularly interesting to know how to handle HTTP interactions with Node without additional libraries. In an era where frameworks are often the answer to "Quick and Dirty" projects, returning to the essentials offers an objective perspective on how a Web server works, while maintaining complete control over the technical stack.
Finally, the Node platform continues to evolve. The native arrival of SQLite is a major advancement, opening the door to other possibilities in the medium term. When will MongoDB integration come? What about database unification through a standardized API!? What was previously reserved for alternative environments such as Deno is gradually becoming accessible from Node itself.