Adhering to software principles ensures project longevity and enables faster delivery of high-quality code—often faster than any code generation tool.
Can we structure our codebase to ship an API or endpoint in under a minute? The short answer: yes, it’s possible.
By following clean code principles, we can design an efficient ExpressJS, MongoDB, and TypeScript API. Combining functional and object-oriented programming, we’ll create a codebase that’s simple to develop and enjoyable to collaborate on, fostering both efficiency and a positive development experience.
Technology Stack:
- ExpressJS: API endpoints
- TypeScript: Type safety and robustness
- Mongoose: MongoDB interaction
Code Structure:
- Model: Defines entity schema
- Entity: Core logic and contract
- BaseRepository: Common database operations
- BaseService: Shared business logic
- BaseController: Common CRUD operations
- AsyncControllerCreator: Generates async controllers with error handling
- RouterCreator: Creates routers for entities
- ValidatorCreator: Validates entity-specific endpoints
This architecture fosters reusable, extensible components, ensuring rapid feature delivery while maintaining flexibility for future changes.
Our folder structure will reflect this approach.
So one thing to really understand here, generic.ts files, for example in repositories the Repository class is generic class that can extend any repository with needed functionality of find, create, update delete method of our database, it can accept any model, this is how we create our reusable logic, in JS we can use mixin for multi-inheritance, to extend derived classes further.
The second thing to understand is index.ts file, which where we create an instance of our app entity repositories, services, controllers.
The full source code can be found here : https://github.com/ARAldhafeeri/ExpressJS-1minute-crud
Let's explain ,
first global types
import { Request, Response, Express, NextFunction } from 'express';
declare global {
type APIResponse<Data> = { data: Data; message: string; status: boolean };
type Controller = (req: Request, res: Response) => void;
type Middleware = (req: Request, res: Response, next: NextFunction) => void;
}
here we created reusable global types to use across the source code.
utils/ControllerCreator.ts
this helps us to remove redundant error handling and focus on implementation when we write our code and return the error via throwing it.
import { Request, Response } from 'express';
export const ControllerCreator =
(fn: Controller ) => (req: Request, res: Response) => {
Promise.resolve(fn(req, res)).catch((err: Error) => {
res.status(400).send({ status: false, message: `Error: ${err.message}` });
});
};
utils/RouterCreator
this helps us to create reduant curd apis, we can create multiple of those , this one takes a controller, we can create one that takes a router and extend it for reuseable endpoints across diffrent entities. Note how we return the router , so we can extended with endpoints specific for that entity.
import { Router } from 'express';
import {IController} from "../entities/generic";
export const RouterCreator = (controller: IController): Router => {
const router = Router();
controller.fetch && router.get('/', (req, res) => controller.fetch!(req, res));
controller.create && router.post('/', (req, res) => controller.create!(req, res));
controller.update && router.put('/', (req, res) => controller.update!(req, res));
controller.delete && router.delete('/', (req, res) => controller.delete!(req, res));
return router;
};
Note here only if the controller define the endpoint within it's implementation it gets the endpoint.
repositories/generics.ts
here we create the base repo, note in child class we can access the model and further extend it with functionality related to the child class
import { IRepository } from '../entities/generic';
import {Model, Document, UpdateQuery} from 'mongoose';
import { FilterQuery, ProjectionType, Types } from 'mongoose';
export class Repository<T> implements IRepository<any> {
constructor(private model: Model<T>) {}
async find(
filter: FilterQuery<T>,
projection: ProjectionType<T>,
): Promise<T[]> {
return this.model.find(filter, projection)
.sort({ updatedAt: -1 })
}
async create(record: T): Promise<any> {
const newRecord = new this.model(record);
return newRecord.save();
}
async update(
filter: FilterQuery<T>,
record: UpdateQuery<T>,
): Promise<any> {
return this.model.findByIdAndUpdate(filter, record, { new: true }).exec();
}
async delete(filter: FilterQuery<T>): Promise<any> {
return this.model.findOneAndDelete(filter).exec();
}
}
entities/generics.ts
import {FilterQuery, ProjectionType, Types, UpdateQuery} from 'mongoose';
export interface IBaseEntity {
_id?: Types.ObjectId;
createdAt?: Date;
updatedAt?: Date;
}
export interface IRepository<T extends IBaseEntity> {
find(
filter: FilterQuery<T>,
projection: ProjectionType<T>,
): Promise<T[]>;
create(record: T): Promise<T>;
update(
filter: FilterQuery<T>,
record: UpdateQuery<T>,
): Promise<T>;
delete(
filter: FilterQuery<T>,
): Promise<T>;
}
export interface IService<T extends IBaseEntity> {
find(id: string ): Promise<T[]>;
create(record: T, id: string): Promise<T>;
update(
record: T,
recordID: string
): Promise<T>;
delete(id: string, organization: string): Promise<T>;
}
export interface IController {
fetch ?: Controller;
create?: Controller;
update?: Controller;
delete?: Controller;
search?: Controller;
}
services/generic.ts
here we create base service, same with repo, we can access the repo via super
import { IService, IBaseEntity, IRepository } from '../entities/generic';
import {Types} from "mongoose";
import ObjectId = Types.ObjectId;
export class Service<T extends IBaseEntity> implements IService<T> {
constructor(protected repository: IRepository<T>) {
}
async find(organization: string ): Promise<any> {
const filter = { organization: organization };
return this.repository.find(filter, {});
}
async create(record: T, organization: string): Promise<T> {
return this.repository.create(record);
}
async update(
record: T,
recordID: string
): Promise<T> {
const filter = {
_id: new ObjectId(recordID),
}
return this.repository.update(filter, record);
}
async delete(id: string): Promise<T> {
return this.repository.delete({_id: id});
}
}
controllers/generic.ts
import { Request, Response } from 'express';
import {ControllerCreator} from "../utils/ControllerCreator";
class Controller<T> {
constructor(protected service: T) {
this.service = service;
}
fetch = ControllerCreator(async (req: Request, res: Response) => {
const data = await (this.service as any).find();
res.status(200).json({ data, status: true, message: 'Data fetched' });
});
create = ControllerCreator(async (req: Request, res: Response) => {
const data = await (this.service as any).create(req.body);
res.status(201).json({ data, status: true, message: 'Created successfully' });
});
update = ControllerCreator(async (req: Request, res: Response) => {
const data = await (this.service as any).update(req.body, req.query.id as string);
res.status(200).json({ data, status: true, message: 'Updated successfully' });
});
delete = ControllerCreator(async (req: Request, res: Response) => {
const data = await (this.service as any).delete(req.query.id as string);
res.status(200).json({ data, status: true, message: 'Deleted successfully' });
});
}
export default Controller;
we have created base contract for our base repo, service, controller , for other classes to inherit from.
now here is the result , we can ship multiple curd APIs, for multiple entities with less than 10 lines of code, note index.ts files within each directory we just create instances so our app is memory optimized.
entities/user.ts
we created the contract for user, also we kept it open for extension.
import {IBaseEntity, IController, IRepository, IService} from "./generic";
export interface IUser extends IBaseEntity {
name: string;
address: string;
}
export interface IUserRepository extends IRepository<IUser> {
}
export interface IUserService extends IService<IUser>{
}
export interface IUserController extends IController {}
models/user.ts
siimple mongose model
import { Schema, model } from 'mongoose';
import { IUser } from '../entities/user';
export const userSchema = new Schema<IUser>(
{
name: { type: String },
address: { type: String },
},
{ timestamps: true }
);
userSchema.index({ name: 'text', address: 'text' });
export default model<IUser>('User', userSchema);
repoisitories/user.ts
we use the core functionality of find, create, update, delete from base repository and we keep the user repository open for extension
import { Repository } from "./generic";
import {IUser, IUserRepository} from "../entities/user";
class UserRepository extends Repository<IUser> implements IUserRepository {
}
export default UserRepository;
services/user.ts
import {Service} from "./generic";
import {IUser, IUserService} from "../entities/user";
class UserService extends Service<IUser> implements IUserService {
}
export default UserService;
controllers/user.ts
import Controller from "./generic";
import {IUserController, IUserService} from "../entities/user";
class UserController extends Controller<IUserService> implements IUserController {
}
export default UserController;
routes/user.ts
import {RouterCreator} from "../utils/RouterCreator";
import {userController} from "../controllers";
const UserRouter = RouterCreator(userController);
export default UserRouter;
app.ts
import express, {Application} from "express";
import UserRouter from "./routes/user";
const App : Application = express();
App.use("/user", UserRouter);
export default App;
I hope you get the idea how powerful this is, we can create multiple reusable code blocks across the source code, across any layer, this will lead to robust source code and very maintainable, readable, bug-free developer experience.
Best regards,
Ahmed,
Top comments (0)