Back to Blog

7 Essential Java Enterprise Patterns for TypeScript

Published: June 24, 2026
7 Essential Java Enterprise Patterns for TypeScript

JavaScript developers love to reinvent architecture. Every few years a new framework declares that patterns are dead, structure is optional, and you can just "keep it simple." Then the codebase hits 50,000 lines, nobody knows where the business logic lives, and suddenly those old Java enterprise patterns everyone mocked start looking pretty good.

There are 30 years of engineering discipline in Martin Fowler's Patterns of Enterprise Application Architecture, and the good news is that every single one translates directly to TypeScript. NestJS was built to bring these patterns to Node.js — modules, decorators, dependency injection, all of it lifted from Spring Boot and adapted for TypeScript. But even outside NestJS, these patterns make any TypeScript codebase better.

This article covers seven essential Java enterprise patterns for TypeScript that improve your TypeScript SaaS codebase, with real code examples for each one.

Java Enterprise Pattern 1: Repository Pattern in TypeScript

The Repository pattern mediates between your domain logic and your data layer. Instead of scattering database queries across your controllers and services, you centralize them behind a collection-like interface.

TypeScript
1interface Repository<T, K = string> {
2  findById(id: K): Promise<T | null>;
3  findAll(filter?: Partial<T>): Promise<T[]>;
4  create(entity: Omit<T, 'id'>): Promise<T>;
5  update(id: K, updates: Partial<T>): Promise<T>;
6  delete(id: K): Promise<void>;
7}
8
9// Domain entity
10class User {
11  constructor(
12    public readonly id: string,
13    public email: string,
14    public readonly createdAt: Date
15  ) {}
16}
17
18// Implementation with [TypeORM](https://typeorm.io/)
19@Entity('users')
20class UserEntity {
21  @PrimaryGeneratedColumn('uuid') id: string;
22  @Column({ unique: true }) email: string;
23  @CreateDateColumn() createdAt: Date;
24}
25
26@Injectable()
27class UserRepository implements Repository<User, string> {
28  constructor(
29    @InjectRepository(UserEntity)
30    private readonly repo: Repository<UserEntity>,
31  ) {}
32
33  async findById(id: string): Promise<User | null> {
34    const entity = await this.repo.findOneBy({ id });
35    return entity ? this.toDomain(entity) : null;
36  }
37
38  async create(data: Omit<User, 'id'>): Promise<User> {
39    const entity = this.repo.create(data);
40    const saved = await this.repo.save(entity);
41    return this.toDomain(saved);
42  }
43
44  private toDomain(entity: UserEntity): User {
45    return new User(entity.id, entity.email, entity.createdAt);
46  }
47}

Spring Boot developers familiar with Java enterprise patterns will recognize this immediately — it is the same JpaRepository<T, ID> interface with a different spelling. The difference is that TypeScript interfaces disappear at runtime, so you need the concrete class for injection. The discipline from Java enterprise patterns is the same: your services never call the ORM directly.

Java Enterprise Pattern 2: Unit of Work in TypeScript

The Unit of Work pattern tracks changes to your domain objects during a business transaction and flushes them all at once. In Java, this is often handled by the EntityManager or Hibernate Session. In TypeScript, TypeORM has built-in support through its QueryRunner.

TypeScript
1@Injectable()
2class OrderService {
3  constructor(
4    private readonly dataSource: DataSource,
5  ) {}
6
7  async createOrder(customerId: string, items: OrderItemDto[]): Promise<Order> {
8    const queryRunner = this.dataSource.createQueryRunner();
9    await queryRunner.connect();
10    await queryRunner.startTransaction();
11
12    try {
13      const orderRepo = queryRunner.manager.getRepository(OrderEntity);
14      const inventoryRepo = queryRunner.manager.getRepository(InventoryEntity);
15
16      const order = orderRepo.create({ customerId, status: 'pending' });
17      const savedOrder = await orderRepo.save(order);
18
19      for (const item of items) {
20        const inventory = await inventoryRepo.findOneBy({ productId: item.productId });
21        if (!inventory || inventory.quantity < item.quantity) {
22          throw new Error(`Insufficient inventory for ${item.productId}`);
23        }
24        inventory.quantity -= item.quantity;
25        await inventoryRepo.save(inventory);
26      }
27
28      await queryRunner.commitTransaction();
29      return this.toDomain(savedOrder);
30    } catch (error) {
31      await queryRunner.rollbackTransaction();
32      throw error;
33    } finally {
34      await queryRunner.release();
35    }
36  }
37}

The Spring Boot equivalent uses @Transactional on the method. TypeORM requires manual transaction management, which is more verbose but also more explicit — you see exactly where the transaction begins and ends. (For a deeper look at database patterns, check our zero-downtime migration strategy.)

Java enterprise patterns with TypeScript code on a programming screen

Java Enterprise Pattern 3: Domain Model in TypeScript

The domain model pattern is where Java enterprise patterns really matter. Most Node.js codebases use anemic domain models — simple data objects with getters and setters but no behavior. All the business logic lives in services. This works until you have five services all implementing the same discount calculation differently.

A rich domain model moves behavior into the domain objects themselves.

TypeScript
1// Anemic (what most TypeScript codebases have)
2class OrderDto {
3  items: OrderItemDto[];
4  couponCode: string;
5  subtotal: number;
6  discount: number;
7  total: number;
8}
9
10// Rich domain model
11class Order {
12  private readonly items: OrderItem[] = [];
13  private coupon: Coupon | null = null;
14
15  constructor(
16    public readonly customerId: string,
17    public readonly id: string = crypto.randomUUID(),
18  ) {}
19
20  addItem(product: Product, quantity: number): void {
21    if (quantity > product.stock) {
22      throw new Error(`Insufficient stock for ${product.name}`);
23    }
24    this.items.push(new OrderItem(product, quantity));
25  }
26
27  applyCoupon(coupon: Coupon): void {
28    if (!coupon.isValidFor(this.items)) {
29      throw new Error('Coupon not applicable to current items');
30    }
31    this.coupon = coupon;
32  }
33
34  get total(): number {
35    const subtotal = this.items.reduce((sum, item) => sum + item.subtotal, 0);
36    return this.coupon ? this.coupon.apply(subtotal) : subtotal;
37  }
38}

The Java equivalent uses the same pattern — a class with private state, public methods that enforce business rules, and computed properties. The TypeScript version of this Java enterprise pattern is cleaner because you can use get accessors and readonly fields without the boilerplate.

Java Enterprise Pattern 4: Specification Pattern in TypeScript

The Specification pattern lets you encapsulate business rules into reusable, composable objects. Instead of writing complex query conditions in your repository, you build specifications that your repository can interpret.

TypeScript
1interface Specification<T> {
2  isSatisfiedBy(candidate: T): boolean;
3  and(other: Specification<T>): Specification<T>;
4  or(other: Specification<T>): Specification<T>;
5}
6
7class ActiveUserSpecification implements Specification<User> {
8  isSatisfiedBy(user: User): boolean {
9    return user.isActive && !user.isSuspended;
10  }
11
12  and(other: Specification<User>): Specification<User> {
13    return new AndSpecification(this, other);
14  }
15
16  or(other: Specification<User>): Specification<User> {
17    return new OrSpecification(this, other);
18  }
19}
20
21class PremiumUserSpecification implements Specification<User> {
22  isSatisfiedBy(user: User): boolean {
23    return user.tier === 'premium' || user.tier === 'enterprise';
24  }
25}
26
27// Usage in a service
28class UserService {
29  constructor(private readonly userRepo: UserRepository) {}
30
31  async getEligibleForPromotion(): Promise<User[]> {
32    const allUsers = await this.userRepo.findAll();
33    const spec = new ActiveUserSpecification()
34      .and(new PremiumUserSpecification());
35    return allUsers.filter(u => spec.isSatisfiedBy(u));
36  }
37}

In Java enterprise patterns, this is common in codebases using Hibernate Criteria or Spring Data JPA Specifications (JpaSpecificationExecutor). The TypeScript version is nearly identical — the main difference is that TypeScript lacks built-in support for runtime type-checking of generic specifications, but functional composition works the same way.

Java Enterprise Pattern 5: Service Layer in TypeScript

The Service Layer pattern defines an application boundary with a clear set of operations. Controllers call services. Services call repositories. Repositories talk to the database. Each layer has exactly one responsibility.

TypeScript
1// Controller — handles HTTP concerns only
2@Controller('orders')
3class OrderController {
4  constructor(private readonly orderService: OrderService) {}
5
6  @Post()
7  @HttpCode(201)
8  async create(@Body() dto: CreateOrderDto, @User() user: AuthenticatedUser) {
9    return this.orderService.createOrder(user.tenantId, dto);
10  }
11}
12
13// Service — business logic and orchestration
14@Injectable()
15class OrderService {
16  constructor(
17    private readonly orderRepo: OrderRepository,
18    private readonly inventoryService: InventoryService,
19    private readonly paymentService: PaymentService,
20  ) {}
21
22  async createOrder(tenantId: string, dto: CreateOrderDto): Promise<Order> {
23    const customer = await this.orderRepo.findCustomer(dto.customerId);
24    if (!customer) throw new NotFoundException('Customer not found');
25
26    const payment = await this.paymentService.charge(dto.paymentToken, dto.total);
27    const order = Order.create(tenantId, dto.items, payment.id);
28    await this.inventoryService.reserve(dto.items);
29    return this.orderRepo.save(order);
30  }
31}

Spring Boot developers will recognize this Java enterprise pattern as the standard three-layer architecture: @RestController ? @Service ? @Repository. NestJS follows the exact same pattern with @Controller() ? @Injectable() service ? @Injectable() repository. The convention is not optional — it is the architecture.

Java Enterprise Pattern 6: Factory Pattern in TypeScript

The Factory pattern centralizes complex object creation logic. When creating a domain object requires multiple steps, validation, or dependencies, a factory keeps that logic from leaking into your constructors.

TypeScript
1interface UserFactory {
2  create(data: CreateUserRequest): User;
3  createAdmin(email: string): User;
4  createFromOAuth(profile: OAuthProfile): User;
5}
6
7class UserFactoryImpl implements UserFactory {
8  constructor(
9    private readonly passwordHasher: PasswordHasher,
10    private readonly idGenerator: IdGenerator,
11  ) {}
12
13  create(data: CreateUserRequest): User {
14    const hashedPassword = this.passwordHasher.hash(data.password);
15    return new User(
16      this.idGenerator.generate(),
17      data.email,
18      hashedPassword,
19      UserRole.USER,
20    );
21  }
22
23  createAdmin(email: string): User {
24    const tempPassword = this.passwordHasher.generateTemporaryPassword();
25    return new User(
26      this.idGenerator.generate(),
27      email,
28      this.passwordHasher.hash(tempPassword),
29      UserRole.ADMIN,
30    );
31  }
32
33  createFromOAuth(profile: OAuthProfile): User {
34    return new User(
35      this.idGenerator.generate(),
36      profile.email,
37      '', // OAuth users authenticate via provider
38      UserRole.USER,
39    );
40  }
41}

In Spring, factories are less common because the framework handles bean creation. In TypeScript, factories are useful when object creation involves side effects (hashing, ID generation, validation) that should not be in the constructor.

Java Enterprise Pattern 7: Observer/Event Pattern in TypeScript

The Observer pattern allows decoupled communication between components. When one part of your system does something interesting, it emits an event. Other parts listen and react. This prevents tight coupling between modules.

TypeScript
1// Event definition
2class OrderCreatedEvent {
3  constructor(
4    public readonly orderId: string,
5    public readonly customerId: string,
6    public readonly total: number,
7    public readonly timestamp: Date = new Date(),
8  ) {}
9}
10
11// Event emitter
12@Injectable()
13class OrderService {
14  constructor(
15    private readonly eventEmitter: EventEmitter2,
16    private readonly orderRepo: OrderRepository,
17  ) {}
18
19  async createOrder(dto: CreateOrderDto): Promise<Order> {
20    const order = await this.orderRepo.save(dto);
21    this.eventEmitter.emit(
22      'order.created',
23      new OrderCreatedEvent(order.id, order.customerId, order.total),
24    );
25    return order;
26  }
27}
28
29// Event listener (decoupled handler)
30@Injectable()
31class EmailNotificationListener {
32  @OnEvent('order.created')
33  async handleOrderCreated(event: OrderCreatedEvent) {
34    await this.emailService.sendOrderConfirmation(event.customerId, event.orderId);
35  }
36}

NestJS provides built-in support for this pattern through @nestjs/event-emitter (for in-process events) and @nestjs/cqrs (for domain events with command/query separation). The Java equivalent uses Spring's ApplicationEventPublisher or a message broker like RabbitMQ. (We covered event-driven architecture in detail in our dedicated post.)

java enterprise patterns are overkill for Small SaaS

Here is the honest assessment:

Start with: Repository and Service Layer. These two patterns alone eliminate the most common SaaS codebase problem — business logic scattered across controllers, routes, and middleware. Every project benefits from these.

Add when needed: Domain Model (when your services start accumulating if-else chains that obviously belong on the data), Unit of Work (when you have multi-table transactions that need atomicity), Observer/Event (when two modules need to communicate but you want to keep them independent).

Skip until you genuinely need them: Specification (most SaaS querying is simple enough that repository methods suffice), Factory (most TypeScript constructors are simple enough without it). These are the patterns you reach for when you feel the pain of not having them, not before.

The risk of adopting all seven Java enterprise patterns on day one is over-engineering — you spend more time on abstractions than on features. The risk of adopting none of them is hitting 30,000 lines of TypeScript with every service importing ORM entities directly, every controller handling business logic, and no one able to tell you where the pricing calculation lives.

Start with Repository and Service Layer from the Java enterprise patterns catalog. Add the others as your codebase grows and the pain becomes specific. That is the Java way — not the ceremony, but the discipline of solving the right problem at the right scale.

TypeScript implementation of Java enterprise patterns showing project structure

Frequently Asked Questions

Enterprise patterns are proven architectural solutions to recurring problems in large-scale applications, cataloged in Martin Fowler's Patterns of Enterprise Application Architecture. They include Repository, Unit of Work, Domain Model, Specification, Service Layer, Factory, and Observer patterns — all of which apply directly to TypeScript and Node.js backends.

Yes. TypeScript supports classes, interfaces, generics, and decorators — the same language features that make enterprise patterns work in Java. The patterns translate almost one-to-one, with TypeScript interfaces replacing Java interfaces and TypeScript classes replacing Java classes. NestJS was explicitly designed to bring these patterns to the Node.js ecosystem.

Start with Repository and Service Layer — they keep your data access and business logic organized. Add Unit of Work when you need atomic multi-repository transactions. Add Domain Model when your business rules are complex enough that anemic models start leaking logic everywhere. Skip Specification and Factory until you actually need them.

The Repository pattern abstracts database access behind a collection-like interface. In TypeScript, define a generic interface with methods like findById, findAll, create, update, delete. Implement it with TypeORM or Prisma. Controllers and services depend on the interface, not the ORM, making them testable and swappable.

An anemic domain model is a data structure with getters and setters but no behavior — all business logic lives in services. A rich domain model encapsulates both data and behavior in the same object. Rich models prevent logic duplication and make the codebase easier to reason about, but require more up-front design.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio