Writing an API takes careful thought about abstraction. I believe API code should be understandable, extensible, and maintainable. The hard part is that good abstraction can be painful to think through.

It definitely helps to use an opinionated framework that gives you a clear structure, such as NestJS. With it, we can split code into modules, services, and controllers. Even so, I personally do not enjoy the decorator-heavy style. Sometimes it feels a little messy to me. That might just be a skill issue, but still, I am not a fan.

As you may know, I am the creator of Bexlite.dev. For backend APIs, my framework of choice is ElysiaJS. I like it because the typing is strong, the performance is great, and the design fits the way I prefer to write code. It makes it easier to keep the code clear, effective, and maintainable over time.

Key Principles for API Abstraction

When I design an API architecture, I try to follow four core principles:

  • Isolation
  • Robust error handling
  • Understandability
  • Maintainability

Isolation

Isolation is essential if we want modular and testable code. By clearly separating validation, business logic, and database access, we can test each part independently. That also makes debugging and future improvements much easier.

Robust Error Handling

Error handling gets difficult very quickly when business logic involves many moving parts. If something fails, we need to know exactly where it happened and why.

Understandability

Abstractions are only useful if people can understand them. Sometimes developers create error handling and abstractions that are so broad that debugging becomes painful because no one can tell what actually went wrong.

Maintainability

Maintainability is really the result of the previous three principles. When code is well-isolated, easy to understand, and built with solid error handling, it becomes much easier to update, extend, and refactor as requirements change.

So how do I structure API code around those principles?

Personally, I usually organize it into three core units:

  • Controllers
  • Repositories
  • Services

Controllers

Controllers should only handle the request and response. Nothing more.

Repository

Repositories should focus only on database operations. They should have a single responsibility.

Services

Services should handle the business logic, such as input validation, interacting with repositories, and shaping data through DTOs.

Let us start with the repository layer.

// this example using plain object instead of class to be easier to understood for beginners
const UserRepository = {
  createUser: async (userData: TNewUser) => {
    try {
      const { username, email, password } = userData;
      const newUser = await db.insert(users).values({ username, email, password }).returning();

      return newUser[0];
    } catch (error) {
      console.error("Error in UserRepository.createUser:", (error as Error).message);
      throw new DatabaseError("Error creating user in database", error.message);
    }
  },

  getUser: async (username: string) => {
    try {
      const user = await db.select().from(users).where(eq(users.username, username));

      if (user.length === 0) {
        throw new NotFoundError("User not found");
      }

      return user[0];
    } catch (error) {
      console.error("Error in UserRepository.getUser:", (error as Error).message);
      if (error instanceof NotFoundError) {
        throw error;
      }
      throw new DatabaseError("Error fetching user from database");
    }
  },
};

As you can see, I created a custom DatabaseError class to make error handling easier. Here is the code:

export class CustomError extends Error {
  public code: number;
  public details?: unknown;

  constructor(message: string, code: number, details?: unknown) {
    super(message);
    this.code = code;
    this.name = this.constructor.name;
    this.details = details;
  }
}

export class DatabaseError extends CustomError {
  constructor(message: string, details?: unknown) {
    super(message, 500, details);
  }
}

export class ValidationError extends CustomError {
  constructor(message: string, details?: unknown) {
    super(message, 400, details);
  }
}

export class AuthError extends CustomError {
  constructor(message: string) {
    super(message, 401);
  }
}

The purpose of this class hierarchy is to provide a message, a status code, and optional error details. I also add console.error inside the repository so it is obvious where an error occurred.

Now let us look at the service layer. Here is an example for a register service:

export const AuthServices = {
  registerUser: async (username: string, email: string, password: string) => {
    const validation = registerSchema.safeParse({ username, email, password });

    // Check input validation
    if (!validation.success) {
      throw new ValidationError("Input validation failed", validation.error.flatten().fieldErrors);
    }

    // Check if user exists
    const user = await UserRepository.getUser(username);

    if (user) {
      throw new AuthError("User already exists");
    }

    // Hash password
    const hashedPassword = await Bun.password.hash(password);

    // Create user
    const newUser = await UserRepository.createUser({
      username: validation.data.username,
      email: validation.data.email,
      password: hashedPassword,
    });

    return newUser;
  },
};

The service layer should handle the business logic: validating input, checking for collisions, and creating the new user through the repository. An even better approach is to use a DTO to filter the returned data so we avoid accidentally leaking sensitive information.

Now let us look at the controller:

export const AuthController = {
  handleRegister: async ({ body, set }: Context) => {
    const { username, email, password } = body;

    try {
      const newUser = await AuthServices.registerUser(username, email, password);

      set.status = 201;
      return {
        message: "User registered successfully",
        data: newUser,
      };
    } catch (error) {
      const err = error as Error;

      if (err instanceof ValidationError) {
        set.status = 400;
      } else if (err instanceof AuthError) {
        set.status = 401;
      } else {
        set.status = 500;
      }

      return {
        message: err.message,
        errors: error.details,
      };
    }
  },
};

Because the controller is only responsible for the request and response, the business logic stays inside the service layer. That separation keeps the API easier to understand and maintain.

I hope this structure is clear. I will probably publish a full example repository soon.