Shortcuts
BlogJuly 10, 2025

Mohamed Elbarry
Backend Architecture Patterns
I’ve worked on a “microservices” codebase that was really a monolith split into five repos with no clear ownership—deployments and debugging were a mess. For most products I start with a well-structured monolith and only split out services when we have a concrete reason (scale, team boundaries, or different tech). Here’s how I think about it. For Lumin AI and Lumin Search we kept a modular monolith with clear layers until we had a reason to split. For small and medium apps, a single codebase with clear layers is easier to ship and change. I keep controllers thin, put logic in services, and hide persistence behind repositories so we can test and swap implementations. The common advice is to stay with a modular monolith until you’re in the ballpark of dozens of developers or clearly different scaling needs per domain—microservices add operational and consistency overhead that only pays off when many teams need to ship independently. Basically don’t split things up until you feel the pain; it’s way easier to refactor a clean monolith than to untangle premature microservices. Martin Fowler on breaking up the monolith is a good read when you do outgrow it.
Tsx
// Typical structure
src/
├── controllers/
├── services/
├── models/
├── middleware/
├── routes/
├── utils/
└── app.ts
When the team or the product grows we can extract a bounded context into its own service later. Doing that from day one usually adds overhead without payoff. I look at microservices when we need different scaling (e.g. one heavy job processor), separate teams owning different domains, or different runtimes (e.g. Python for ML). Each service should have a clear API and its own data store so we’re not sharing DBs and creating hidden coupling.
Tsx
// One service as a small app
const app = express();
app.use(express.json());

const userRepository = new UserRepository();
const userService = new UserService(userRepository);
const userController = new UserController(userService);

app.get('/users/:id', userController.getUser);
app.post('/users', userController.createUser);
app.put('/users/:id', userController.updateUser);
app.delete('/users/:id', userController.deleteUser);

export default app;
I model the domain with entities and value objects and keep persistence behind interfaces. That keeps business logic testable without a real DB and makes it obvious where data comes from.
Tsx
// Domain entity
export class User {
  private constructor(
    private readonly id: UserId,
    private email: Email,
    private name: string,
    private status: UserStatus
  ) {}

  static create(email: string, name: string): User {
    return new User(
      UserId.generate(),
      new Email(email),
      name,
      UserStatus.ACTIVE
    );
  }

  changeEmail(newEmail: string): void {
    this.email = new Email(newEmail);
  }

  isActive(): boolean {
    return this.status === UserStatus.ACTIVE;
  }
}

// Repository interface
interface UserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
  delete(id: string): Promise<void>;
}

// Implementation
class PostgresUserRepository implements UserRepository {
  constructor(private db: Database) {}

  async findById(id: string): Promise<User | null> {
    const row = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
    return row ? this.toDomain(row) : null;
  }

  private toDomain(row: any): User {
    return new User(
      new UserId(row.id),
      new Email(row.email),
      row.name,
      row.status
    );
  }
}
When the same data is read in very different shapes (e.g. list view vs audit log) or write load is high, I separate command and query paths. Commands change state; queries are read-only and can use different stores or projections.
Tsx
class CreateUserCommand {
  constructor(
    public email: string,
    public name: string
  ) {}
}

class CreateUserCommandHandler {
  constructor(
    private userRepository: UserRepository,
    private eventStore: EventStore
  ) {}

  async handle(command: CreateUserCommand): Promise<void> {
    const user = User.create(command.email, command.name);
    await this.userRepository.save(user);
    await this.eventStore.append(
      user.getId(),
      new UserCreatedEvent(user.getId(), user.getEmail())
    );
  }
}
When we have several services, a single entry point (API gateway or BFF) routes by path and handles auth and rate limiting. The gateway doesn’t hold business logic—it forwards to the right service.
Tsx
class ApiGateway {
  constructor(
    private userService: UserService,
    private orderService: OrderService,
    private productService: ProductService
  ) {}

  async handleRequest(request: Request): Promise<Response> {
    const { path } = request;

    if (path.startsWith('/api/users')) {
      return await this.userService.handleRequest(request);
    } else if (path.startsWith('/api/orders')) {
      return await this.orderService.handleRequest(request);
    } else if (path.startsWith('/api/products')) {
      return await this.productService.handleRequest(request);
    }

    return new Response('Not Found', { status: 404 });
  }
}
Start with a layered monolith: controllers, services, repositories, domain where it pays off. Add CQRS or event sourcing only when read/write or audit requirements justify it. Split into services when we have a concrete scaling or team boundary reason. One thing to try: draw the boundaries between “user”, “order”, “payment” in your current app and see if a single deployable unit still makes sense—if yes, keep it simple. Domain-Driven Design Distilled is a short read if you want to go deeper on bounded contexts. Good luck with it. I'm currently looking for new challenges in the AI and Full Stack space. If you're building something interesting, let's chat.
Share this post: