Skip to content

Instantly share code, notes, and snippets.

  • Select an option

  • Save gbrennon/d6552bc0bf93a5ebf9823cff9c74458c to your computer and use it in GitHub Desktop.

Select an option

Save gbrennon/d6552bc0bf93a5ebf9823cff9c74458c to your computer and use it in GitHub Desktop.
Dependency Injection - Principles Practices and Patterns - Tailored for a Nodejs and TypeScript context

Dependency Injection - Principles, Practices and Patterns

Book by Mark Seemann and Steven van Deursen

This document serves as a distilled summary of the book, tailored for a Node.js and TypeScript context.

Note

At the end of this summary, you will find section How We Do DI at Tactile which outlines our approach to Dependency Injection together with Clean Code and Unit Testing.

The book explores the core concepts of dependency injection, emphasising its role in promoting loose coupling, testability, and adherence to SOLID principles. It covers foundational DI patterns, anti-patterns, and advanced practices like Pure DI, lifetime management and Cross-Cutting Concerns with Aspect Oriented Programming.

Throughout this summary, code snippets and notes illustrate how these principles can be effectively applied in modern Node.js and TypeScript projects.

PART 1 - Putting Dependency Injection on the map

Chapter 1 - The basics of Dependency Injection: What, why, and how

DEFINITION Dependency Injection is a set of software design principles and patterns that enables you to develop loosely coupled code.

NOTE In DI terminology, we often talk about services and components. A service is typically an Abstraction, a definition for something that provides a service. An implementation of an Abstraction is often called a component, a class that contains behavior. Because both service and component are such overloaded terms, throughout this book, you’ll typically see us use the terms “Abstraction” and “class” instead.

What purpose does DI serve? DI isn’t a goal in itself; rather, it’s a means to an end. Ultimately, the purpose of most programming techniques is to deliver working software as efficiently as possible. One aspect of that is to write maintainable code.

Tip

Program to an interface, not an implementation.

IMPORTANT Programming to interfaces doesn’t mean that all classes should implement an interface. It typically makes little sense to hide POCOs, DTOs, and view models behind an interface, because they contain no behaviour that requires mocking, Interception, or replacement

DI is essentially about passing dependencies to objects instead of having them create dependencies themselves. This practice leads to "inversion of control," where the responsibility of creating and supplying dependencies is delegated elsewhere.

Benefits of DI: reduced coupling, easier unit testing, and improved readability and maintainability.

DEFINITION Pure DI is the practice of applying DI without a DI Container.

Dependency Categories

It can be helpful to categorise your Dependencies into Stable Dependencies and Volatile Dependencies. In general, Dependencies can be considered stable by exclusion. They’re stable if they aren’t volatile.

Note

  • A Dependency should be considered Volatile Dependency if any of the following criteria are true:
  • The Dependency introduces a requirement to set up and configure a runtime environment for the application.
    • Databases are good examples of BCL types that are Volatile Dependencies, and relational databases are the archetypical example. If you don’t hide a relational database behind a Seam, you can never replace it by any other technology. It also makes it hard to set up and run automated unit tests.
    • Other out-of-process resources like message queues, web services, and even the filesystem fall into this category. The symptoms of this type of Dependency are lack of late binding and extensibility, as well as disabled Testability.
  • The Dependency doesn’t yet exist, or is still in development.
  • The Dependency isn’t installed on all machines in the development organization.
  • The Dependency contains nondeterministic behavior. This is particularly important in unit tests because all tests must be deterministic. Typical sources of nondeterminism are random numbers and algorithms that depend on the current date or time.

Note

  • The important criteria for Stable Dependencies include the following:
    • The class or module already exists.
    • You expect that new versions won’t contain breaking changes.
    • The types in question contain deterministic algorithms.
    • You never expect to have to replace, wrap, decorate, or Intercept the class ormodule with another.

IMPORTANT Volatile Dependencies are the focal point of DI. It’s for Volatile Dependencies rather than Stable Dependencies that you introduce Seams into your application. Again, this obligates you to compose them using DI.

NOTE As developers, we gain control by removing a class’s control over its Dependencies. This is an application of the Single Responsibility Principle. Classes shouldn’t have to deal with the creation of their Dependencies.

Tip

Liskov Substitution Principle

This principle states that we should be able to replace one implementation of an interface with another without breaking either the client or the implementation.

When it comes to DI, the Liskov Substitution Principle is one of the most important software design principles. It’s this principle that enables us to address requirements that occur in the future, even if we can’t foresee them today.

Note

Seams

Everywhere you decide to program against an Abstraction instead of a concrete type, you introduce a Seam into the application. A Seam is a place where an application is assembled from its constituent parts, similar to the way a piece of clothing is sewn together at its seams. It’s also a place where you can disassemble the application and work with the modules in isolation.

Chapter 2 - Writing tightly coupled code

Tip

Single Responsibility Principle

SRP states that each class should only have a single responsibility, or, better put, a class should have only one reason to change

It’s easy to create tightly coupled code. Although not all tight coupling is bad, tight coupling to Volatile Dependencies is and should be avoided.

Cohesion is defined as the functional relatedness of the elements of a class or module. The lower the amount of relatedness, the lower the cohesion; and the lower the cohesion, the greater the chance a class violates the Single Responsibility Principle.

Chapter 3 - Writing loosely coupled code

Preferred are slow, step-by-step refactorings. That’s not to say that refactoring is easy, because it’s not. It’s hard.

Decoupling to achieve "a system where changes to one part of the application don’t ripple through and require changes in others." Abstractions, such as interfaces, serve as contracts, allowing classes to depend on stable contracts rather than volatile concrete implementations. Interfaces, abstract classes, or dependency injection patterns help ensure that classes are "programmed to abstractions" rather than to specific details, adhering to the Dependency Inversion Principle (DIP).

Tip

Composition Root We’d like to be able to compose our classes into applications in a way similar to how we plug electrical appliances together. This level of modularity can be achieved by centralizing the creation of our classes into a single place. We call this location the Composition Root.

The composition root is located as close as possible to the application’s entry point

Note

Interfaces or abstract classes?

Many guides to object-oriented design focus on interfaces as the main abstraction mechanism, whereas some Design Guidelines endorse abstract classes over interfaces

Should you use interfaces or abstract classes? With relation to DI, the reassuring answer is that it doesn’t matter we, as authors, don’t have a preference for one over the other.

We do, in fact. When it comes to writing applications, we typically prefer interfaces over abstract classes for these reasons:

  • Abstract classes can easily be abused as base classes. Base classes can easily turn into ever-changing, ever-growing God Objects. The derivatives are tightly coupled to its base class, which can become a problem when the base class contains Volatile behavior. Interfaces, on the other hand, force us into the “Composition over Inheritance” mantra.

  • Concrete classes can implement several interfaces, while JavaScript classes cannot extend multiple Abstract Classes or regular classes directly because JS does not support multiple inheritance. Thus, using interfaces as the vehicle of Abstraction is more flexible.

  • Interface definitions are (a bit) less clumsy compared to abstract classes, making the interface a more succinct definition.

export abstract class UserService {
 abstract getUserById(id: string): Promise<User>;
}

// 17% less code using interfaces
export interface UserService {
  getUserById(id: string): Promise<User>;
}

DI Container is a software library that provides DI functionality and automates many of the tasks involved in object composition, intercaption, and Lifetime management. Di containers are also known as inversion of controL (IoC) containers.

When practicing Pure Di, the composition root typically contains this information in a coherent way. Even better, it gives you a view of the complete object graph, not just the direct Dependencies of a class, which is what you get with tightly coupled code

Stable vs. Volatile Dependencies

Stable Dependencies:

  • Rarely change.
  • Typically used across multiple components without needing frequent updates.
  • Can be instantiated once and shared, often suitable as a singleton.
  • Injection Approach: Constructor injection is ideal because it allows the dependency to be set once and used throughout the lifecycle of the class.
  • Example:
    • Logger is a common example of a stable dependency. It performs simple logging and is unlikely to change.

Volatile Dependencies:

  • Subject to frequent updates or changes
  • May have configurations or instances that need to be refreshed or changed.
  • Injection Approach: Method injection is preferred to allow flexibility, as it enables injecting a new instance each time the method is called.
  • Example:
    • GetGroup is an example of a volatile dependency, as it interacts a specific AB Test / user context so it needs to be instantiated for every device request.

PART 2 - Catalog

Chapter 4 - DI Patterns

Pattern 1 - COMPOSITION ROOT

Where and how you should compose an application’s object graphs.

Where should we compose object Graphs ? As close as possible to the application's entry point

Tip

DEFINITION A Composition Root is a single, logical location in an application where modules are composed together.

WARNING If you use a DI Container, the Composition Root should be the only place where you use the DI Container. Using a DI Container outside the Composition Root leads to the Service Locator anti-pattern.

Note

Why do we want a composition root ? When you write loosely coupled code, you create many classes to create an application. It can be tempting to compose these classes at many different locations in order to create small subsystems, but that limits your ability to Intercept those systems to modify their behavior. Instead, you should compose classes in one single area of your application. Moving the composition of classes out of the Composition Root leads to either the Control Freak or Service Locator anti-patterns.

Pattern 2 - CONSTRUCTOR INJECTION

Allows a class to statically declare its required Dependencies.

IMPORTANT Keep the constructor free of any other logic to prevent it from performing any work on Dependencies. The Single Responsibility Principle implies that members should do only one thing. Now that you’re using the constructor to inject Dependencies, you should keep it free of other concerns. This makes the construction of your classes fast and reliable.

Tip

DEFINITION A Local Default is a default implementation of a Dependency that originates in the same module or layer.

LOCAL DEFAULT: It would be tempting to make that implementation the default used by the class under development. But when such a prospective default is implemented in a different assembly, using it as a default means creating a hard reference to that other assembly, effectively violating many of the benefits of loose coupling and becoming a FOREIGN DEFAULT, doing some sort of control freak anti-pattern

If you need to make the Dependency optional, you can change to Property Injection if it has a proper Local Default.

Warning

WARNING Dependencies should hardly ever be optional. Optional Dependencies complicate the consuming component with null checks. Instead, make Dependencies required, and create and inject Null Object implementations in cases where there’s no reasonable implementation available for the required Dependency.

Pattern 3 - METHOD INJECTION

Enables you to provide a Dependency to a consumer when either the Dependency or the consumer might change for each operation.

Tip

DEFINITION Method Injection supplies a consumer with a Dependency by passing it as method argument on a method called outside the Composition Root.

Method Injection is different from other types of DI patterns in that the injection doesn’t happen in a Composition Root but, rather, dynamically at invocation.

There are two typical use cases for applying Method Injection:

  • When the consumer of the injected Dependency varies on each call.
  • When the injected Dependency varies on each call to a consumer.

Temporal Coupling is a common problem in API design. It occurs when there’s an implicit relationship between two or more members of a class, requiring clients to invoke one member before the other. This tightly couples the members in the temporal dimension. The archetypical example is the use of an Initialize method

Pattern 4 - PROPERTY INJECTION

Allows clients to optionally override some class’s default behavior, where this default behavior is implemented in a Local Default.

Tip

DEFINITION Property Injection allows a Local Default to be replaced via a public settable property. Property Injection is also known as Setter Injection.

Property Injection should only be used when the class you’re developing has a good Local Default, and you still want to enable callers to provide different implementations of the class’s Dependency. It’s important to note that Property Injection is best used when the Dependency is optional. If the Dependency is required, Constructor Injection is always a better pick.

NOTE The concept of opening a class for extensibility is captured by the Open/ Closed Principle that, briefly put, states that a class should be open for exten- sibility, but closed for modification. When you implement classes following the Open/Closed Principle, you may have a Local Default in mind, but you still provide clients with a way to extend the class by replacing the Dependency with something else

TIP Sometimes you only want to provide an extensibility point, leaving the Local Default as a no-op.

choosing-which-pattern-to-use

Choosing with pattern to use

Chapter 5 - DI Anti-Patterns

DEFINITION An anti-pattern is a commonly occurring solution to a problem, which generates decidedly negative consequences, although other documented solutions that prove to be more effective are available.

CONTROL FREAK Anti-pattern

The "Control Freak" anti-pattern can happen even when using Pure DI, especially if classes start taking over the responsibility of creating or controlling dependencies instead of just receiving them.

In this example, we’re using Pure DI but introduce control logic within the class that still makes it act like a "Control Freak."

Caution

BAD CODE BELOW

class SendGridService {
  send(email: string, message: string) {
    console.log(`Sending email to ${email}: ${message}`);
  }
}

class EmailService {
  private sendGridService?: SendGridService;

  // This method takes control of dependency creation
  private initializeSendGridService() {
    if (!this.sendGridService) {
      this.sendGridService = new SendGridService();
    }
  }

  sendEmail(email: string, message: string) {
    this.initializeSendGridService(); // Control Freak behavior
    this.sendGridService!.send(email, message);
  }
}

class UserService {
  constructor(private emailService: EmailService) {}

  notifyUser(email: string, message: string) {
    this.emailService.sendEmail(email, message);
  }
}

// Composition root
const emailService = new EmailService();
const userService = new UserService(emailService);

// Using the service
userService.notifyUser("example@example.com", "Hello from Pure DI!");

In a correct setup, EmailService should receive SendGridService in the constructor and should not control its instantiation:

class EmailService {
  constructor(private sendGridService: SendGridService) {}

  sendEmail(email: string, message: string) {
    this.sendGridService.send(email, message);
  }
}

// Composition root
const sendGridService = new SendGridService();
const emailService = new EmailService(sendGridService);
const userService = new UserService(emailService);

// Using the service
userService.notifyUser("example@example.com", "Hello from Pure DI!");

Now EmailService is only responsible for using SendGridService, not for creating or controlling it. This avoids the Control Freak anti-pattern, keeping the design flexible, testable, and easy to maintain.

Note

What about Factories as a solution of Control Freak ?

Factories can still introduce Control Freak behavior if they are not properly used. If a factory is inside a class and is responsible for creating the class’s own dependencies, that still violates DI principles. The class becomes a "factory" in disguise, essentially instantiating its own dependencies. This is what Seemann refers to as a potential Control Freak pattern, as the class would be responsible for its own creation and potentially too much control over the dependency lifecycle.

However, when used correctly, injecting a factory into a class for creating dependencies can avoid the Control Freak pattern, but only when they are injected from outside the class (ideally from the composition root), and when they are used to handle situations like transient dependencies or complex instantiation logic that cannot be easily handled by the class constructor alone. The key point is that factories should not be used to create dependencies from inside the class itself. Instead, factories delegate the responsibility of instantiating dependencies to an external source.

In this way, factories can help solve problems where you need a fresh instance of a dependency each time, while maintaining proper separation of concerns. When a class has a dependency factory injected, it’s still not in control of the creation of its dependencies —the responsibility remains external.

// Transaction is a transient dependency
class Transaction {
  private id: string;

  constructor() {
    this.id = Math.random().toString(36).substring(2);
  }

  commit() {
    console.log(`Committing transaction ${this.id}`);
  }
}

class TransactionFactory {
  create() {
    return new Transaction();
  }
}

class UserService {
  constructor(private transactionFactory: () => TransactionFactory) {}

  performAction() {
    const transaction = this.transactionFactory.create(); // Using injected factory
    transaction.commit();
  }
}

// Composition Root: Instantiating and injecting dependencies
const transactionFactory = () => new Transaction()
const userService = new UserService(transactionFactory);

// Running the service
userService.performAction();

SERVICE LOCATOR Anti-pattern

DEFINITION A Service Locator supplies application components outside the Composition Root with access to an unbounded set of Volatile Dependencies.

It’s a pattern where the class locates its dependencies through a global registry or locator, rather than having them explicitly injected. This can lead to several issues, including tight coupling, hidden dependencies, and reduced testability. The Service Locator essentially hides the dependencies of a class, making it harder to understand what a class depends on and complicating testing and maintaining the code.

In this example, the ServiceLocator is a global singleton that holds all services and allows retrieval via the get() method.

Caution

BAD CODE BELOW

class ServiceLocator {
  private static services: Map<string, any> = new Map();

  // Register a service with the locator
  static register(serviceName: string, service: any) {
    ServiceLocator.services.set(serviceName, service);
  }

  // Get a service from the locator
  static get(serviceName: string) {
    const service = ServiceLocator.services.get(serviceName);
    if (!service) {
      throw new Error(`Service ${serviceName} not found`);
    }
    return service;
  }
}

Caution

BAD CODE BELOW

class TransactionService {
  public startTransaction() {
    console.log('Transaction started...');
  }
}

class UserService {
  private transactionService: TransactionService;

  constructor() {
    // Instead of dependency injection, we use the service locator to fetch the dependency
    this.transactionService = ServiceLocator.get('TransactionService');
  }

  public createUser() {
    console.log('Creating user...');
    this.transactionService.startTransaction(); // Using transaction service
  }
}

Service Locator is a dangerous pattern because it almost works. There’s only one area where it falls short, and that shouldn’t be taken lightly: It impacts the reusability of the classes consuming it.

This manifests itself in two ways:

  • The class drags along the Service Locator as a redundant Dependency.
  • The class makes it non-obvious what its Dependencies are.

AMBIENT CONTEXT Anti-pattern

Related to Service Locator is the Ambient Context anti-pattern. Where a Service Locator allows global access to an unrestricted set of Dependencies, an Ambient Context makes a single strongly typed Dependency available through a static accessor.

DEFINITION An Ambient Context supplies application code outside the Composition Root with global access to a Volatile Dependency or its behavior by the use of static class members.

Ambient Context is similar in structure to the Singleton pattern. Both allow access to a Dependency by the use of static class members. The difference is that Ambient Context allows its Dependency to be changed, whereas the Singleton pattern ensures that its singular instance never changes.

This creates a situation where we have:

  • Implicit dependencies: The class accesses services or data that are not passed explicitly as constructor parameters but are available through some global or shared context.
  • Hidden coupling: Dependencies are hidden, making the class less modular and harder to test or reuse.
  • Global state: Often, the context is managed through global variables, singletons, or thread-local storage, which can make it difficult to track or manage dependencies.
  • Reduced testability: Since dependencies are implicit, unit tests become challenging. The class becomes dependent on the global state, and mocking or replacing these dependencies for testing purposes becomes more complex.
  • Difficulty in tracking: As the class doesn’t declare its dependencies explicitly, understanding what it requires becomes difficult by just inspecting the class itself.

In this example, UserService is implicitly dependent on the currentUser global context. The class doesn’t declare that it relies on this context, which leads to several problems:

Caution

BAD CODE BELOW

// Ambient context: global state holding information about the current user
let currentUser: { name: string, role: string } | null = null;

// A service that relies on the global context
class UserService {
  public greetUser() {
    if (currentUser) {
      console.log(`Hello, ${currentUser.name}!`);
    } else {
      console.log('Hello, Guest!');
    }
  }

  public isAdmin() {
    return currentUser?.role === 'admin';
  }
}

Caution

BAD CODE BELOW

// Setting the global state somewhere in the application
currentUser = { name: 'Alice', role: 'admin' };

const userService = new UserService();

userService.greetUser();  // Output: "Hello, Alice!"

// Changing the global context
currentUser = { name: 'Bob', role: 'user' };
userService.greetUser();  // Output: "Hello, Bob!"

Tip

To fix the Ambient Context anti-pattern, you should explicitly inject the dependencies into the class, rather than relying on global state.

class UserService {
  constructor(private currentUser: { name: string, role: string } | null) {}

  public greetUser() {
    if (this.currentUser) {
      console.log(`Hello, ${this.currentUser.name}!`);
    } else {
      console.log('Hello, Guest!');
    }
  }

  public isAdmin() {
    return this.currentUser?.role === 'admin';
  }
}

CONSTRAINED CONSTRUCTION Anti-pattern

The Constrained Construction anti-pattern occurs when an object or class is designed in a way that it has a rigid constructor, forcing the object to be instantiated with specific dependencies that cannot be easily substituted or extended. This limits flexibility and scalability, as the object becomes tightly coupled to the specific configuration of dependencies it was designed with.

In this example, the PaymentService class has a constrained constructor that takes four different services as parameters (PaymentGateway, TaxService, DiscountService, and Logger). These dependencies are tightly coupled, and the constructor requires all of them to be provided when an instance of PaymentService is created, even when you might only make use of processPayment

Caution

BAD CODE BELOW

class PaymentService {
  constructor(
    private paymentGateway: IPaymentGateway,
    private otherPaymentGateway: OtherPaymentGateway,
    private taxService: TaxService,
    private discountService: DiscountService,
    private logger: Logger
  ) {}

  public processPayment(order: Order) {
    const tax = this.taxService.calculateTax(order);
    const discount = this.discountService.applyDiscount(order);
    const totalAmount = order.amount + tax - discount;

    this.paymentGateway.process(totalAmount);
    this.logger.log('Payment processed');
  }
  
  public processOtherPayment(order: Order) {
    const tax = this.taxService.calculateTax(order);
    const discount = this.discountService.applyDiscount(order);
    const totalAmount = order.amount + tax - discount;

    this.otherPaymentGateway.process(totalAmount);
    this.logger.log('Payment processed');
  }
}

Tip

One way to fix it is by using factories as discussed before, and another dangerous alternative is Setter Injection:

class PaymentService {
  private paymentGateway!: PaymentGateway;
  private taxService!: TaxService;
  private discountService!: DiscountService;
  private logger!: Logger;

  public setPaymentGateway(paymentGateway: PaymentGateway) {
    this.paymentGateway = paymentGateway;
  }

  public setTaxService(taxService: TaxService) {
    this.taxService = taxService;
  }

  public setDiscountService(discountService: DiscountService) {
    this.discountService = discountService;
  }

  public setLogger(logger: Logger) {
    this.logger = logger;
  }

  public processPayment(order: Order) {
    const tax = this.taxService.calculateTax(order);
    const discount = this.discountService.applyDiscount(order);
    const totalAmount = order.amount + tax - discount;

    this.paymentGateway.process(totalAmount);
    this.logger.log('Payment processed');
  }
}

// Create an instance of PaymentService and set what you need
const paymentService = new PaymentService();
paymentService.setPaymentGateway(new PaymentGateway());
paymentService.setTaxService(new TaxService());
paymentService.setDiscountService(new DiscountService());
paymentService.setLogger(new Logger());

paymentService.processPayment(order);

The Constrained Construction anti-pattern can be more relevant in TypeScript when you have classes that extend each other. This pattern typically involves situations where a class constructor imposes unnecessary or excessive restrictions on how objects are instantiated, which can be more pronounced in inheritance hierarchies.

Caution

BAD CODE BELOW

class Logger {
  log(message: string) {
    console.log(message);
  }
}

class BaseService {
  constructor(private logger: Logger) {}

  log(message: string) {
    this.logger.log(message);
  }
}

class PaymentService extends BaseService {
  constructor(private paymentGateway: string, logger: Logger) {
    // The constructor requires a logger, even if PaymentService doesn't directly need it
    super(logger); // Base class constructor enforces logger requirement
  }

  processPayment(order: string) {
    console.log(`Processing payment for ${order} via ${this.paymentGateway}`);
  }
}

// Usage
const logger = new Logger();
const paymentService = new PaymentService("PayPal", logger);
paymentService.processPayment("order123");

Note

Why is this a Constrained Construction ?

  • Inflexibility in Subclass: PaymentService is forced to take the logger dependency because the BaseService constructor requires it. This forces the subclass to either pass the logger dependency or create a circular dependency if it tries to configure it differently.

  • Inheritance Restriction: The constructor signature of BaseService places unnecessary restrictions on PaymentService. If PaymentService doesn't need logger for its primary operations, this dependency adds unnecessary coupling and limits the flexibility of the PaymentService constructor.

  • Tight Coupling: The PaymentService class becomes tightly coupled to the BaseService constructor. It cannot independently decide whether to pass a logger dependency or configure it in some other way, which reduces flexibility and introduces unnecessary constraints.

Chapter 6 - Code Smells

Tip

Unless you have special requirements, Constructor Injection should be your preferred injection pattern.

Having many Dependencies is an indication of a Single Responsibility Principle (SRP) violation. SRP violations lead to code that’s hard to maintain.

When a constructor’s parameter list grows too large, we call the phenomenon Constructor Over-injection and consider it a code smell.

Note

Constructor Injection makes it easy to spot SRP violations. Instead of feeling uneasy about Constructor Over-injection, you should embrace it as a fortunate side effect of Constructor Injection. It’s a signal that alerts you when a class takes on too much responsibility.

Warning

NOTE A tempting, but erroneous, attempt to resolve Constructor Over-injection is through the introduction of Property Injection, perhaps even by moving those properties into a base class. Although the number of constructor Dependencies can be reduced by replacing them with properties, such a change doesn’t lower the class’s complexity, which should be your primary focus.

DEFINITION A Facade Service hides a natural cluster of interacting Dependencies, along with their behavior, behind a single Abstraction.

THE LEAKY ABSTRACTION CODE SMELL: One way this can happen is if you only extract an interface from a given concrete type, but some of the parameter or return types are still concrete types defined in the library you want to abstract from. If you need to extract an interface, you need to do it in a recursive manner, ensuring that all types exposed by the root interface are themselves interfaces. We call this Deep Extraction, and the result is Deep Interfaces.

Proxy design pattern: this pattern provides a surrogate or placeholder for another object to control access to it. It allows deferring the full cost of its creation and initialization until you need to use it. A Proxy implements the same interface as the object it’s surrogate for. It makes consumers believe they’re talking to the real implementation.

Tip

INTERFACE SEGREGATION PRINCIPLE: (ISP) states that “No client should be forced to depend on methods it doesn’t use.” This means that a consumer of an interface should use all the methods of a consumed Dependency. If there are methods on that Abstraction that aren’t used by a consumer, the interface is too large and, according to the ISP, the interface should be split up.

Tip

Dependency Inversion Principle: Abstractions should be owned by the layer using the Abstraction.

Warning

NOTE Ever-changing Abstractions are a strong indication of SRP violations. This also relates to the Open/Closed Principle (OCP), which states that you should be able to add features without having to change existing classes.

NOTE The more methods a class has, the higher the chance it violates the Single Responsibility Principle. This is also related to the Interface Segregation Principle, which prefers narrow interfaces.

NOTE Classes should never perform work involving Dependencies in their constructors. Besides making object construction slow and unreliable, using an injected Dependency might fail, because it may not yet be fully initialized.

PART 3 - Pure DI

Chapter 7 - Application Composition

You can write all the required components well in advance and only compose them when you absolutely must

At runtime, the first thing that happens is Object Composition.

Tip

DEFINITION Object Composition is the act of building up hierarchies of related components. This composition takes place inside the Composition Root.

NOTE You should separate the loading of configuration values from the methods that do Object Composition. This decouples Object Composition from the configuration system in use, making it possible to test without the existence of a (valid) configuration file.

Chapter 8 - Object Lifetime

DEFINITION Composer is a unifying term to refer to any object or method that composes Dependencies. It’s an important part of the Composition Root. The Composer is often a DI Container, but it can also be any method that constructs object graphs manually (using Pure DI).

DEFINITION A Lifestyle is a formalized way of describing the intended lifetime of a Dependency.

Tip

SIMPLE DEPENDENCY LIFECYCLE You know that DI means you let a third party (typically our Composition Root) serve the Dependencies you need. This also means you must let it manage the Dependencies’ lifetimes

Tip

LISKOV SUBSTITUTION PRINCIPLE “Methods that consume ABSTRACTIONS must be able to use any class derived from that ABSTRACTION without noticing the difference.”

We must be able to substitute the ABSTRACTION for an arbitrary implementation without changing the correctness of the system. Failing to adhere to the Liskov Substitution Principle makes applications fragile, because it disallows replacing Dependencies, and doing so might cause a consumer to break.

DEFINITION An ephemeral disposable is an object with a clear and short lifetime that typically doesn’t exceed a single method call.

Notice that the ephemeral disposable is never injected into the consumer. Instead, a factory is used, and you use that factory to control the lifetime of the ephemeral disposable.

Note

THE SINGLETON LIFESTYLE Use the Singleton Lifestyle whenever possible. Two main issues that might prevent you from using a Singleton follow:

  • ( Not a concern in NODE ) When a component isn’t thread-safe. Because the Singleton instance is potentially shared among many consumers, it must be able to handle concurrent access.
  • When one of the component’s DEPENDENCIES has a lifetime that’s expected to be shorter, possibly because it isn’t thread-safe.

Note

THE TRANSIENT LIFESTYLE It involves returning a new instance every time it’s requested.

The Transient Lifestyle is the safest choice of Lifestyles, but also one of the least efficient. It can cause a myriad of instances to be created and garbage collected, even when a single instance would have sufficed.

WARNING Although you can mix Dependencies with different Lifestyles, you should make sure that a consumer only has Dependencies with a lifetime that’s equal to or exceeds its own, because a consumer will keep its Dependencies alive by storing them in its private fields. Failing to do so leads to Captive Dependencies

Note

THE SCOPED LIFESTYLE DEFINITION Scoped Dependencies behave like Singleton Dependencies within a single, well-defined scope or request but aren’t shared across scopes. Each scope has its own cache of associated Dependencies.

The Scoped Lifestyle makes sense for long-running applications that are tasked with processing operations that need to run with some degree of isolation. Isolation is required when these operations are processed in parallel, or when each operation contains its own state

BAD LIFESTYLE CHOICES - CAPTIVE DEPENDENCIES

When it comes to lifetime management, a common pitfall is that of Captive Dependencies. This happens when a Dependency is kept alive by a consumer for longer than you intended it to be. This might even cause it to be reused by multiple threads or requests concurrently, even though the Dependency isn’t thread-safe.

Tip

DEFINITION A Captive Dependency is a Dependency that’s inadvertently kept alive for too long because its consumer was given a lifetime that exceeds the Dependency’s expected lifetime.

Tip

IMPORTANT A component should only reference Dependencies that have an expected lifetime that’s equal to or longer than that of the component itself.

BAD LIFESTYLE CHOICES - LEAKY DEPENDENCIES

Another case where you might end up with a bad Lifestyle choice is when you need to postpone the creation of a Dependency. When you have a Dependency that’s rarely needed and is costly to create, you might prefer to create such an instance on the fly, after the object graph is composed. This is a valid concern. What isn’t, however, is pushing such a concern on to the Dependency’s consumers. If you do this, you’re leaking details about the implementation and implementation choices of the Composition Root to the consumer. The Dependency becomes a Leaky Abstraction, and you’re violating the Dependency Inversion Principle.

Lazy loaded dependencies are a type of leaky dependencies. This is useful, because it allows you to delay the creation of Dependencies. It’s an error, however, to inject Lazy directly into a consumer’s constructor, you should keep the constructors of your components free of any logic other than Guard Clauses and the storing of incoming Dependencies. This makes the construction of your classes fast and reliable, and will prevent such components from ever becoming expensive to instantiate.

In some cases, however, you’ll have no choice; for instance, when dealing with third- party components you have little control over. In that case, Lazy is a great tool. But rather than letting all consumers depend on Lazy, you should hide Lazy behind a Virtual Proxy and place that Virtual Proxy within the Composition Root. To prevent this, you could make all Dependencies lazy by default, because, in theory, every Dependency could potentially become expensive in the future. This would prevent you from having to make any future cascading changes. But this would be madness, and we hope you agree that this isn’t a good path to pursue.

This doesn’t mean that you aren’t allowed to construct your Dependencies lazily, though. We’d like, however, to repeat our statement from section 4.2.1: you should keep the constructors of your components free of any logic other than Guard Clauses and the storing of incoming Dependencies. This makes the construction of your classes fast and reliable, and will prevent such components from ever becoming expensive to instantiate.

In some cases, however, you’ll have no choice; for instance, when dealing with third- party components you have little control over. In that case, Lazy is a great tool. But rather than letting all consumers depend on Lazy, you should hide Lazy behind a Virtual Proxy and place that Virtual Proxy within the Composition Root

Chapter 9 - Interception

Tip

Interception allows you to inject logic into the invocation of a dependency's methods. It acts as a wrapper around a service, adding pre-processing, post-processing, or even replacing the behavior of method calls.

For example:

  • Logging input/output of a method.
  • Validating arguments before method execution.
  • Adding caching to avoid redundant computations.

DEFINITION Interception is the ability to intercept calls between two collabo- rating components in such a way that you can enrich or change the behavior of the Dependency without the need to change the two collaborators themselves.

DEFINITION Cross-Cutting Concerns are aspects of a program that affect a larger part of the application. They’re often non-functional requirements. They don’t directly relate to any particular feature, but, rather, are applied to existing functionality.

Important

IMPORTANT The set of software design principles and patterns around DI (such as, but not limited to, loose coupling and the Liskov Substitution Principle) are the enablers of Interception. Without these principles and patterns, it’s impossible to apply Interception.

Example: test-agent’s CappedLogger

Chapter 10 - Aspect-Oriented Programming by design

Tip

AOP is a programming paradigm that complements object-oriented programming by providing a way to separate cross-cutting concerns. Cross-cutting concerns are functionalities that affect multiple parts of an application and don't fit neatly into a single class or module, such as:

  • Logging
  • Authentication
  • Error handling
  • Metrics

For web applications, middleware can be used to handle cross-cutting concerns like logging, authentication or to measure metrics.

const logMiddleware = (req: Request, res: Response, next: NextFunction) => {
    console.log(`Request to ${req.method} ${req.url}`);
    next();
};

app.use(logMiddleware);

app.get('/hello', (req, res) => res.send('Hello, world!'));

app.listen(3000, () => console.log('Server running on port 3000'));

Note

DEFINITION Aspect-Oriented Programming aims to reduce boilerplate code required for implementing Cross-Cutting Concerns and other coding patterns. It does this by implementing such patterns in a single place and applying them to a code base either declaratively or based on convention, without modifying the code itself.

NOTE AOP as a paradigm focuses on working around the problem of repetition.

Important

The SOLID principles

S ingle Responsibility Principle (SRP): every class should have a single reason to change.

O pen/Closed Principle (OCP): a class should be open for extension, but closed for modifica- tion.

L iskov Substitution Principle (LSP): every dependency should behave as defined by its Abstraction

I nterface Segregation Principle (ISP): promotes the use of fine-grained Abstractions, rather than wide Abstractions. Any time a consumer depends on an Abstraction where some of its members are unused, the ISP is violated.

D ependency Inversion Principle (DIP): you should program against Abstractions, and that the consuming layer should be in control of the shape of a consumed Abstraction

Note

Because they both try to prevent sweeping changes, there’s a strong relationship between the OCP principle and the Don’t Repeat Yourself (DRY) principle. OCP, however, focuses on code, whereas DRY focuses on knowledge

Command-Query Separation promotes the idea that each method should either:

  • Return a result, but not change the observable state of the system
  • Change the state, but not produce any value. Meyer called the value-producing methods queries and the state-changing methods commands. The idea behind this separation is that methods become easier to reason about when they’re either a query or a command, but not both.

DEFINITION A Parameter Object is a group of parameters that naturally go together.

Tip

Well-designed applications have few code lines that log:

  • Instead of logging unexpected situations while continuing execution, throw exceptions.
  • Instead of catching unexpected exceptions, logging, and continuing in the middle of an operation, prefer leaving exceptions unhandled and let them bubble up the call stack. Letting an operation fail fast allows exceptions to be logged at a single location at the top of the call stack and prevents giving users the illusion that their request completed successfully.
  • Make methods small.

DEFINITION A passive attribute provides metadata rather than behavior. Passive attributes prevent the Control Freak anti-pattern, because aspect attributes that include behavior are often Volatile Dependencies.

Important

It can be difficult to apply SOLID principles. It takes time, and you’ll never be 100% SOLID.

NOTE None of the SOLID principles represents absolutes. They’re guidelines that can help you write clean code.

NOTE Cross-Cutting Concerns should be applied at the right granular level in the application

How We Do DI at Tactile

At Tactile, we embrace Dependency Injection with a pragmatic and minimal approach tailored to our TypeScript + Node.js backend systems. Our practices reflect Pure DI principles, favoring clarity, modularity, and testability over the use of external containers.

1 - Principles we focus on

  • Single Responsibility Services (SRP): We design service classes to have a single reason to change and be easily testable.
  • Interfaces & Abstractions (LSP, ISP, DIP): We program to interfaces and always rely on Abstractions for decoupling and extensibility.
  • Constructor Injection is our default method for required dependencies.

Important

Constructor Injection is used to express required dependencies clearly and enforces immutability at runtime. It’s also the preferred method to identify SRP violations early.

Tip

This DeleteGameItem class is a great example of how we apply SRP, as we take it seriously but not to the extreme. You could argue that "Validating that the GameItem CAN BE deleted" and "Actually Deleting it" are two different responsibilities, but we treat them as part of the same Cohesive Concern of "game item deletion”. Keeping the checks here reduces complexity and improves readability. But, if the validation logic ever becomes more complex, then we’d extract it to its own service.

export class DeleteGameItem implements IDeleteGameItem { // <-- Concrete Service class programed to an Interface
    constructor(
        private readonly findOne: IFindOneCommand<IGameItem>, // <-- Constructor Injection against an Interface
        private readonly deleteById: IDeleteByIdCommand,
    ) {}

    async deleteOne(gameItemId: string): Promise<void> {
        const existingGameItem = await this.findOne.execute({
            query: { _id: new ObjectId(gameItemId) }
        });

        if (!existingGameItem) {
            throw new NotFoundError(`Game item ${gameItemId} not found.`);
        }

        if (!existingGameItem.archived) {
            throw new BadRequestError('Game item must be archived before deleting.');
        }

        await this.deleteById.execute({
            _id: new ObjectId(gameItemId)
        });
    }
}

Tip

That said, we can go smaller, for example in this DeleteDevice service class, its half its size. Less is Better

export class DeleteDevice implements IDeleteDevice {
    constructor(
        private readonly deleteById: IDeleteByIdCommand,
        private readonly clearDeviceCache: IClearDeviceCache,
    ) {}

    public async delete(device: IDevice): Promise<void> {
        await this.deleteById.execute({ _id: device._id });
        await this.clearDeviceCache.clear(device);
    }
}

2 - We look at injections for code smells signaling we are doing too much.

Important

We try to keep volatile dependencies injections under 3. But its NOT a HARD limit. We know we have services that will do more and have more dependencies. But having more than 3 is a smell that the class might be doing too much. and some logical segregation of responsibilities could improve the design. For example this JourneyEvaluator is likely OK, but the ProgressEvaluationEventProcessor is very likely doing too much and should be split into smaller services.

export class JourneyEvaluator implements IJourneyEvaluator {
    constructor(
        private readonly playerStateSegmentEvaluator: IEvaluatePlayerStateSegment,
        private readonly journeyProgressAnalyticsEventSender: IJourneyProgressAnalyticsEventSender,
        private readonly entitiesCache: IEntitiesCache, // <-- 5 Injections is not great but acceptable.
        private readonly journeyPreconditionsEvaluators: IJourneyPreconditionsEvaluator[],
        private readonly stageEvaluator: IStageEvaluator,
    ) {}

    async evaluateJourney(
        journey: IJourney,
        progression: IProgression,
        event: IEvent,
        evaluationResult: IUpdateProgressionAndEvaluateJourneysResult,
        debuggingMetrics: IDebuggingMetrics,
    ) {
        // Some logic to evaluate the journey
    }
export class ProgressEvaluationEventProcessor implements IEventProcessor {
    constructor(
        private readonly progressionResolver: IProgressionResolver,
        private readonly playerSettingsUpdater: IPlayerSettingsUpdater,
        private readonly playerStateSegmentReevaluator: IPlayerStateSegmentReevaluation,
        private readonly journeysProcessor: IJourneysProcessor,
        private readonly playerStateComposer: IPlayerStateComposer,
        private readonly actionResolverManager: IActionResolverManager,
        private readonly entitiesCache: IEntitiesCache,
        private readonly progressionService: IProgressionService,
        private readonly getGroupFactory: (deviceId: string, runningTests?: IABTestMeta[]) => IGetGroup,
        private readonly eventsLogsCache: IEventLoggerCache,
        private readonly globalStateEventsNames: string[], // <-- 13 Injections is too much, should be split into smaller concerns.
        private readonly getServerABTests?: IGetServerABTests,
        private readonly fluidABTestTreatment?: IFluidABTestTreatment,
    ) {}

    async process(journeyContext: IJourneyContext, event: IEvent, abTestContext?: IRequestABTestContext): Promise<IEventProcessingResult> {
      //  Some logic to process the event
    }

3 - Command-Query Separation (CQS)

DEFINITION Command-Query Separation (CQS) states that each method should either perform an action (a command) that changes application state without returning data, or return data (a query) without changing state — but never both.

To help with SRP we try keep CQS in mind when designing our services. This promotes the idea that each method should either:

  • Return a result, but not change the state
  • Change the state, but not return any value

Note

The idea behind this separation is that methods become easier to reason about when they are either a query or a command, but not both. Here we see the Find and Delete being query and command respectively. Commands would mostly return void.

export class DeleteGameItem implements IDeleteGameItem {
    constructor(
        private readonly deleteByIdCommand: IDeleteByIdCommand,
        private readonly findOneCommand: IFindOneCommand<IGameItem>,
    ) {}

    async deleteOne(gameItemId: string): Promise<void> { // <-- Command, no returned value
        // Some logic to delete a game item
    }
export class FindOneGameItem implements IFindOneGameItem {
    constructor(private readonly findOneCommand: IFindOneCommand<IGameItem, true>) {}

    findOne(game: string, gameItemId: string): Promise<IGameItem | null> { // <-- Query, returns a GameItem
        return this.findOneCommand.execute({ query: { _id: new ObjectId(gameItemId), game } });
    }
}

4 - Stable Dependencies

Things that are deterministic, stateless, and internal to our domain can be imported directly without needing to inject them. These are considered stable dependencies. In the context of Dependency Injection, these stable dependencies are fine to import directly, as they are considered part of the internal behavior of the class and do not need to be swapped or configured externally.

In the following example we see generateRegex being imported directly, as it is a pure utility function that is deterministic and does not change based on external configuration or state. It is stable and does not need to be injected.

export class FindGameItems implements IFindGameItems {
    constructor(private readonly findManyCommand: IFindManyCommand<IGameItem, true>) {}

    find({ game, searchText, archived, categories }: IFindGameItemsQuery): Promise<IGameItem[] | null> {
        const operation: IFindManyOperation = {
            query: { game, categories: { $in: categories }, archived },
        };

        if (searchText) {
            const ids = generateRegex(searchText, 'i');
            const displayName = generateRegex(searchText, 'i');
            const description = generateRegex(searchText, 'i');
            operation.query = {
                ...operation.query,
                $or: [{ ids }, { displayName }, { description }],
            };
        }

        return this.findManyCommand.execute(operation);
    }
}

5 - Testing Strategy

Important

Our testing strategy is layered:

  • Unit Tests: First-class citizens, mocking dependencies to test components in isolation. We aim for high coverage here, ensuring each service behaves as expected and all relationships are interacting correctly.
  • Integration Tests: Used to validate object composition and module integration at handler or router level. Its main objective is to bring more trust to the API specification, DB Schemas, cache interactions and to ensure the "glue/composition" code that wires routes , services, and repositories together works as expected.

We like to have a ratio of 10 unit tests per integration test.

We pair our Clean Code principles with a Testing Strategy focused on Unit Testing. Where the heavy lifting of testing all code paths is done by easy to set up and mock unit tests. We seek to make large amounts of single-assertion unit tests to have as many individual clues as possible to understand what is happening when something changes Mocking and creating fakes is easy nowadays with the help of libraries like jest. They run fast, isolated, with no side-effects.

Unit Tests Example

describe('DeleteGameItem', () => {
    let deleteGameItem: IDeleteGameItem, deleteByIdCommand: IDeleteByIdCommand, findOneCommand: IFindOneCommand<IGameItem>;

    beforeEach(() => {
        findOneCommand = mock<IFindOneCommand<IGameItem>>();
        deleteByIdCommand = mock<IDeleteByIdCommand>();
        deleteByIdCommand.execute = jest.fn().mockResolvedValue(1);
        deleteGameItem = new DeleteGameItem(deleteByIdCommand, findOneCommand);
    });

    afterEach(jest.resetAllMocks);

    describe('deleteOne', () => {
        it('should throw `NotFoundError` if the game item is not found', async () => {});
        it('should throw `BadRequestError` if the game item is not archived', async () => {});
        it('should delete if the game item', async () => {});
        it('should query the game item with the correct ObjectId', async () => {});
        it('should delete the game item with the correct ObjectId', async () => {});
    });
});

Integration Tests Example

describe('DeleteGameItem', () => {
    let deleteGameItem: IDeleteGameItem, mongoCommands: IMongoCommands<IGameItem>, logger: Logger;

    beforeAll(async () => {
        logger = mock<Logger>();
        await connectDBCommands(logger);
        mongoCommands = new MongoCommandsFactory<IGameItem>(gameItemsCollectionDefinition, logger).createCommands();
    });

    afterAll(disconnectDBCommands);

    beforeAll(() => {
        deleteGameItem = new DeleteGameItem(mongoCommands.findOne, mongoCommands.deleteById);
    });

    afterEach(async () => {
        await mongoCommands.deleteMany.execute({ query: {} });
    });

    describe('deleteOne', () => {
        it('should delete an archived game item', async () => {});
        it('should throw if the delete operation fails', async () => {});
    });
});

6 - Pure DI & Composition Roots

  • We do not use DI containers. We do Pure DI, all in plain TypeScript.
  • We compose our object graphs manually in a central place, the Composition Root, usually called appBootstrap.
  • We then implement multiple compositions we call them bootstraps, one per module.
  • Each module exports only from index.ts, enforcing encapsulation.

This leads to what we believe is a clear-looking Composition Root — making the object graph easier to reason about.

export function gameItemsBootstrap(): IGameItemsBootstrap {
    const { findMany, insertOne, findOne, updateById, updateMany, deleteById } = new MongoCommandsFactory<IGameItem, true>(
        gameItemsCollectionDefinition,
        logger,
    ).createCommands();

    const gameItemsCloudStorage = TactileGoogleCloudStorage.fromKeyFile(auth);

    const findGameItems = new FindGameItems(findMany);
    const createGameItem = new CreateGameItem(findOne, insertOne);
    const findOneGameItem = new FindOneGameItem(findOne);
    const updateGameItem = new UpdateGameItem(findOne, updateById);
    const updateManyGameItem = new UpdateManyGameItem(findMany, updateMany);
    const deleteGameItem = new DeleteGameItem(deleteById, findOne);
    const mediaUploadGameItem = new MediaUploadGameItem(gameItemsCloudStorage, bucket);
    const mediaDeleteGameItem = new MediaDeleteGameItem(gameItemsCloudStorage, bucket);
    const deleteGameItemCategory = new DeleteGameItemCategory(updateMany);

    return {
        findGameItems,
        createGameItem,
        findOneGameItem,
        updateGameItem,
        updateManyGameItem,
        deleteGameItem,
        mediaUploadGameItem,
        mediaDeleteGameItem,
        deleteGameItemCategory,
    };
}
export function appBootstrap(): IAppBootstrap {
    // [.. Other modules bootstraps..]

    const pushNotifications = pushNotificationsBootstrap();
    const userSegmentsResources = userSegmentsResourcesBootstrap(pushNotifications.findPushNotificationsByUserSegment);
    const playerStateSegments = playerStateSegmentsBootstrap();
    const gameItems = gameItemsBootstrap(); // <-- Game Items Module Bootstrap
    const userSupportMessages = userSupportMessagesBootstrap(pushNotifications);  // <-- Module dependencies are passed explicitly here
    const embrace = embraceBootstrap();
    const builds = buildsBootstrap();

    // [.. Other modules bootstraps..]

    return {
        playerStateSegments,
        gameItems,
        userSupportMessages,
        builds,
        embrace,
        //  [.. Other modules exports..]
    };
}

Note

To maintain loose coupling between these modules, we have a convention, where other modules —including the composition root — should only use what’s exported from a module’s index.ts file, since thats the only piece of code that was designed for external use — everything else is internal by design. Only what is Abstracted is exported.

export { IGameItem } from './interfaces/IGameItem';
export { IGameItemsBootstrap } from './interfaces/IGameItemsBootstrap';

export { gameItemsRouter } from './router';

export { gameItemsBootstrap } from './bootstrap';

Lastly, that application bootstrap is one of the very first thing our application does in app.ts. If there's anything wrong with it, we will find out soon, not after a specific code path is executed, so no surprises.

const { gameItems, crashRecovery } = appBootstrap();

const app = express();

app.use('/:game/game-items', gameItemsRouter(gameItems)); // <-- Game Items Router gets injected with the Game Items Module Bootstrap
app.use('/:game/crash-recovery', crashRecoveryRouter(crashRecovery));

So from appBootstrap, we get all our instances and factories. Then, the service class instances are "propped down" to the routers and then the handlers.By being passed explicitly as parameters to routers and handlers we believe it increases visibility, traceability, and testability.

Its worth noting that we try to apply all the same principles as with the service classes here in our routers and handlers, meaning we try to keep them small, focused, and with a single responsibility. This way we can easily test them in isolation, and they are easy to reason about.

export function gameItemsRouter(gameItems: IGameItemsBootstrap): Router {
    const app = Router({ mergeParams: true });
    const { findGameItems, createGameItem, findOneGameItem, updateGameItem, deleteGameItem } = gameItems;

    app.use(openApiValidatorMiddleware);

    app.get('', authorize(viewPermission), findGameItemsHandler(findGameItems));
    app.post('', authorize(editPermission), createGameItemHandler(createGameItem));
    app.get('/:gameItemId', authorize(viewPermission), findOneGameItemHandler(findOneGameItem));
    app.put('/:gameItemId', authorize(editPermission), updateGameItemHandler(updateGameItem));
    app.delete('/:gameItemId', authorize(editPermission), deleteGameItemHandler(deleteGameItem)); // <-- Game Items Handlers get injected with the Game Items service classes instances

    app.use(errorHandlerMiddleware(logger));

    return app;
}
export function deleteGameItemHandler(deleteGameItem: IDeleteGameItem) { // <-- Delete Game Item service class injected
    return async (req: ITactileRequest, res: Response) => {
        const { params } = req;
        const { gameItemId } = params;

        await deleteGameItem.deleteOne(gameItemId); // <-- Delete Game Item service class used

        return void res.status(204).send();
    };
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment