Что такое composition root

Что такое composition root Техника

Further Reading

Dependency Injection Principles, Practices and Patterns

Dependency Injection in NodeJS – by Rising Stack

Dependency Injection in NodeJS – by Jeff Hansen (Awilix Author)

Dependency Injection Vantagens, Desafios e Approaches – by Talysson Oliveira (Portuguese)

Six approaches to dependency injection – by Scott Wlaschin

We want to work with you. Check out our «What We Do» section!

Зачем выделять зависимости?

Каждый раз, когда мы выделяем зависимость в самостоятельную абстракцию, мы преследуем определенную цель (или несколько целей).

В-первых, путем выделения зависимости мы можем повысить уровень абстракции и оградить весь остальной код от ненужных подробностей. Для этого, например, мы можем выделить отдельные интерфейсы/ классы для доступа к удаленным сервисам, слою доступа данных и т.п. Мы просто хотим думать не в терминах реализации (WCF, NHibernate), а в терминах абстракции (AbstractService, CustomRepostory). Не нужно быть гуру в современных новомодных принципах разработки, чтобы прийти к выводу, что подобные операции стоит спрятать куда-нибудь поглубже, и не размазывать их использование ровным слоем по всему приложению.

Во-вторых, возможно, мы хотим получить «конфигурируемость» поведения путем использования паттернов «Стратегия», «Абстрактная фабрика» и тому подобных. То есть мы с самого начала знаем, что у нас будет несколько реализаций, и создаем соответствующий дизайн с самого начала.

ПРИМЕЧАНИЕ

Марк Сииман в своей книге приводит очень интересное наблюдение, подтвержденное и моей собственной практикой. Многие разработчики и архитекторы забывают, что у любого решения есть и обратная сторона, и гибкость в этом вопросе не исключение. На самом деле, не такое уж много приложений требует гибкой настройки своего поведения путем переконфигурирования приложения. Обычно повторное развертывание приложения с заменой текущей реализации может быть достаточно «гибким» решением, особенно когда это развертывание происходит на собственных серверах. Поэтому, прежде чем закладываться на настраиваемость всего и вся, подумайте о том, насколько это вам действительно нужно.

В-третьих, мы можем выделять зависимость для того, чтобы протестировать логику нашего класса в изоляции без внешнего окружения. Для этого первым шагом является выделение набора операций в абстрактный интерфейс с «протаскиванием» его через конструктор класса.

Инверсия зависимостей на практике

Суть инверсии зависимостей сводится к тому, что класс перекладывает ответственность за создание зависимости или получение ее экземпляра на более высокий уровень, в результате чего он сам не создает экземпляр конкретного класса, а получает его от более высокоуровневого кода через конструктор или свойство. В результате мы заменяем композицию агрегацией и перекладываем часть проблем с текущего класса куда-то выше.

В некоторых случаях «где-то выше» может быть непосредственно вызывающим кодом, когда речь заходит о локальных стратегиях. В этом случае вызывающий код точно знает, какая стратегия ему требуется и использует ее напрямую (как, например, в случае с сортировкой):

 sortedArray =  SortedList<, >(
                              CustomComparer());

Существует вариация предыдущего случая, когда вызывающий код не создает зависимость напрямую, но знает о классе/объекте, которые могут в этом помочь. Иногда более высокоуровневый код знает о некотором локальном или глобальном контексте, недоступном низкоуровневому классу, с помощью которого можно получить экземпляр требуемой зависимости.

 wcfTracer =  WcfTracer(Logger.GetLogger());

Вызываемый код (класс WcfTracer) может физически не иметь доступа к классуLogger, поскольку добавление конкретной зависимости усложнит его повторное использование. Но вызывающий код уже может знать о том, где получить экземпляр логгера для конкретного контекста использования или конкретного приложения (кстати, это упрощенный пример реального кода, используемого в одном из наших приложений).

Оба эти варианта достаточно простые и, если возможно, то стоит использовать именно их, поскольку в этом случае наш «граф» объектов не размазывается по всему приложению, а ограничивается лишь несколькими классами.Суть инверсии зависимостей сводится к тому, что мы не хотим захламлять текущий класс лишними деталями, но мы и не хотим размазывать эти детали тонким слоем по всему приложению.

ПРИМЕЧАНИЕ

Может показаться, что этот совет противоречит общепринятым DI-практикам, однако это не так. Помните разговор об стабильных и изменчивых зависимостях? Все дело в том, что «изменчивость» зависимости зависит от контекста: для одного уровня – это изменчивая зависимость от реализации которой нам нужно «абстрагироваться», а для другого уровня – это стабильная зависимость, которую можно использовать напрямую.

Любая зависимость – это шов на теле приложения. Чем он будет короче, тем легче его скрыть и от него избавиться; некоторые зависимости являются деталями реализации модуля и выставление их наружу может нарушить инкапсуляцию модуля.

Далеко не всегда вызывающий класс может получить экземпляр зависимости самостоятельно. Если это невозможно, то он поступает аналогичным образом – перекладывает решение о создании или получении зависимости на еще более высокий уровень. Здесь тоже есть несколько вариантов. Более высокоуровневый класс может потребовать эту же зависимость от своего вызывающего класса (через конструктор или свойство) или поднять уровень абстракции, сгруппировав несколько абстракций в одну, требуя от своего «верхнего уровня» абстрактную фабрику или объект с набором зависимостей.

Достаточно очевидно, что такое перекладывание ответственности не может продолжаться вечно, поскольку кто-то в конечном итоге должен принять решение о том, какие конкретные классы соответствуют каким интерфейсам приложения. В разных типах приложений это место выглядит по-разному, но не зависимо от этого, оно имеет одно название – Composition Root.

ПРИМЕЧАНИЕ

И опять может показаться, что проблема постоянного перекладывания ответственности имеет простое решение – использование контейнера напрямую. В результате контейнер будет выступать в роли Service Locator-а, что многими экспертами считается анти-паттерном (и подробнее об этом мы еще поговорим).

Build vs Use

Earlier, we talked about the difference between building and using a service however, as both the service dependencies and its parameters are all mixed, our code does not reflect this distinction very clearly.

To change that, we’re going to use a function (which in this context is called a factory function) whose sole purpose is to build our service, by receiving the service’s dependencies as parameters:

// randomNumber.ts
export type RandomGenerator = () => number;
export const makeRandomNumber = (randomGenerator: RandomGenerator) => (max: number): number => { return Math.floor(randomGenerator() * (max + 1)); };
// Quick Note:
// The above construct is called a Higher Order Function (HOC),
// because it returns another function.
//
// If it feels weird because of the arrow function notation,
// but it is equivalent to this:
export function makeRandomNumber(randomGenerator: RandomGenerator) { return function randomNumber(max: number): number { return Math.floor(randomGenerator() * (max + 1)); };
}
export const randomNumber = makeRandomNumber(Math.random);

Previously, randomNumber‘s dependencies (randomGenerator) were mixed with its ordinary parameters (max), that is, the parameters randomNumber‘s clients have to pass to the “built randomNumber version”.

Now, we’re using a factory function (makeRandomNumber) to gather these dependencies and explicitly separate randomNumber‘s construction from its usage, which is also reflected in the functions’ names.

Aside from the gain in conceptual clarity, this separation will prove to be very useful soon, as it is the basis of some of the techniques surrounding DI.

Наследование vs Композиция vs Агрегация

Между двумя классами/объектами возможны разные типы отношений. Самым базовым типом отношений является ассоциация (association). Это означает, что два класса как-то связаны между собой, и мы пока не знаем точно, в чем эта связь выражена, и собираемся уточнить ее в будущем. Обычно это отношение используется на ранних этапах дизайна, чтобы показать, что зависимость между классами существует, и двигаться дальше.

Что такое composition root
Рисунок 1. Отношение ассоциации

Более точным типом отношений является отношение открытого наследования (отношение «является», IS A Relationship), которое говорит, что все, что справедливо для базового класса, справедливо и для его наследника. Именно с его помощью мы получаем полиморфное поведение, абстрагируемся от конкретной реализации классов, имея дело лишь с абстракциями (интерфейсами или базовыми классами), и не обращаем внимание на детали реализации.

Что такое composition root
Рисунок 2. Отношение наследование

И хотя наследование является отличным инструментом в руках любого ОО-программиста, его явно недостаточно для решения всех типов задач. Во-первых, далеко не все отношения между классами определяются отношением «является», а во-вторых, наследование является самой сильной связью между двумя классами, которую невозможно разорвать во время исполнения (это отношение является статическим и в строго типизированных языках определяется во время компиляции).

В этом случае на помощь приходит другая пара отношений: композиция (composition) и агрегация (aggregation). Оба они моделируют отношение «является частью» (HAS-A Relationship) и обычно выражаются в том, что класс целого содержит поля (или свойства) своих составных частей. Грань между ними достаточно тонкая, но важная, особенно в контексте управления зависимостями.

Что такое composition root
Рисунок 3. Отношение композиции и агрегации

СОВЕТ

Пара моментов, чтобы легче запомнить визуальную нотацию: (1) ромбик всегда находится со стороны целого, а простая линия – со стороны составной части; (2) закрашенный ромб означает более сильную связь – композицию, не закрашенный ромб показывает более слабую связь – агрегацию.

Разница между композицией и агрегацией заключается в том, что в случае композиции целое явно контролирует время жизни своей составной части (часть не существует без целого), а в случае агрегации целое хоть и содержит свою составную часть, время их жизни не связано (например, составная часть передается через параметры конструктора).

  CompositeCustomService
{
   
      CustomRepository _repository
          =  CustomRepository();
      DoSomething()
    {
       
    }
}
 AggregatedCustomService
{
   
      AbstractRepository _repository;
    AggregatedCustomService(AbstractRepository repository)
    {
        _repository = repository;
    }
      DoSomething()
    {
       
    }
}

CompositeCustomService для управления своими составными частями использует композицию, аAggregatedCustomService – агрегацию. При этом явный контроль времени жизни обычно приводит к более высокой связанности между целым и частью, поскольку используется конкретный тип, тесно связывающий участников между собой.

С одной стороны, такая жесткая связь может не являться чем-то плохим, особенно когда зависимость является стабильной (см. раздел «Стабильные и изменчивые зависимости» выше). С другой стороны, мы можем использовать композицию и контролировать время жизни объекта, не завязываясь на конкретные типы. Например, с помощью абстрактной фабрики:

   IRepositoryFactory
{
    AbstractRepository Create();
}
 CustomService
{
   
      IRepositoryFactory _repositoryFactory;
    CustomService(IRepositoryFactory repositoryFactory)
    {
        _repositoryFactory = repositoryFactory;
    }
      DoSomething()
    {
        repository = _repositoryFactory.Create();
       
    }
}

В данном случае мы не избавляемся от композиции (CustomService все еще контролирует время жизни AbstractRepository, но делает это не напрямую, а с помощью дополнительной абстракции – абстрактной фабрики). Поскольку такой подход требует удвоения количества классов наших зависимостей, то его стоит использовать, когда явный контроль времени жизни является необходимым условием.

Интересной особенностью разных отношений между классами является то, что логичность их использования может зависеть от точки зрения проектировщика, от того, с какой стороны он смотрит на задачу, и какие вопросы он задает себе при ее анализе. Именно поэтому одну и ту же задачу можно решить десятком разных способов, при этом в одном случае мы получим сильно связанный дизайн с большим количеством наследования и композиции, а в другом случае эта же задача будет разбита на более автономные строительные блоки, объединяемые между собой с помощью агрегации.

Например, нашу задачу с сервисами и репозитариями можно решить множеством разных способов. Кто-то скажет, что здесь подойдет наследование и сделает SqlCustomService наследником отAbstractCustomService; другой скажет, что этот подход неверен, поскольку CustomService у нас один, а иерархия должна быть у репозитариев.

Что такое composition root
Рисунок 4. Наследование vs Агрегация

Все варианты приводят к одному и тому же конечному результату, при этом связанность изменяется от очень высокой (при наследовании) к очень слабой (при агрегации).

Интерфейс != слабосвязанный дизайн

В последнее время мода на использование интерфейсов распространилась так, что очень часто их начинают выделять «автоматом», считая это залогом «слабосвязанного дизайна» и тестируемости приложения. И хотя правильное выделение зависимостей и использование абстракций (интерфейсов и абстрактных классов), а не конкретных реализаций (конкретных классов), и правда приводит к слабой связанности (low coupling), чрезмерное их использование может привести к обратному результату.

Сколько раз вы видели класс, принимающий 10 зависимостей в конструкторе? Или класс, который принимает в конструкторе сервис-локатор или DI-контейнер, и дергает потом из него зависимости при необходимости? И хотя в таком случае мы не завязываемся на конкретные классы, такой дизайн едва ли можно назвать слабосвязанным, ведь для того, чтобы разобраться в том, что же он требует на вход, придется проанализировать всю его реализацию.

Скотт Мейерс (гуру С++) самым важным принципом проектирования считает следующий: ваш класс или модуль должно быть легко использовать правильно и сложно использовать неправильно. Классом же с десятком параметром правильно пользоваться весьма сложно; точнее, достаточно просто пользоваться им в давно отконфигурированном приложении с использованием DI-контейнеров, но создать и «угодить» ему с нуля – дело совсем не простое.

Именно поэтому использование сервис-локатора или внедрение обязательных зависимостей через свойства являются антипаттернами. В этом случае невероятно просто использовать класс неправильно и сложно использовать правильно.

Сам факт наличия у класса большого количества зависимостей говорит о проблемах с дизайном и либо о нарушении принципа Единственной Ответственности и нечетком контракте класса, либо о неправильной группировке операций по интерфейсам, либо о чрезмерной гибкости, которая, возможно, так и не понадобилась.

Дополнительно:  Не включается ноутбук: что делать? | Ремонтник ПК

Даже если ваш класс завязан лишь на абстракции (т.е. на интерфейсы) – это не значит, что связанность отсутствует, просто вместо явной зависимости от конкретных реализаций, вы получили неявную зависимость (implicit coupling) на множество «абстракций», на порядок их создания, на их состояние в момент использования, на определенные «детали» их реализации, на стабильность их интерфейсов, что делает сложным понимание того, «как же вся эта хрень работает».

Выделение зависимости в интерфейс добавляет в приложение «шов» (seam), благодаря которому мы можем «вклиниться» в существующее поведение. Подобные швы дают определенную гибкость, но делают это не бесплатно – если швов слишком много, то мы получаем слишком сложное решение, более сложное в понимании и сопровождении.

Зависимости сами по себе не являются чем-то плохим, пока они используются в ограниченном количестве мест, позволяют легко сопровождать код и писать для него Unit-тесты, и основной способ борьбы с ними сводится не столько к выделению интерфейсов, сколько к ограничению их области применения. При этом помните, что параноидальное выделение интерфейсов и протаскивание их десятками с помощью контейнеров не делает дизайн слабосвязанным и понятным. Код остается формально тестируемым, но стоимость его сопровождения (и стоимость сопровождения тестов) будет не меньше, чем у исходного якобы «монолитного» дизайна с мелкими конкретными классами.

Ambient Context

Существуют некоторые зависимости, которые используются десятками классов. В этом случае попытка передать их через конструкторы приведет к тому, что каждый класс будет содержать не менее 3-4 параметров, а классы более высокого уровня будут вынуждены знать о десятке зависимостей, требуемых на несколько уровней ниже. В этом случае можно воспользоваться паттерном под названием Ambient Context (глобальный или внешний контекст).

По своей сути, Ambient Context очень похож на синглтон, в том плане, что он содержит статический член (обычно свойство), через который можно получить и установить некоторую зависимость. Ключевое отличие заключается в том, что Ambient Context оперирует абстракцией (интерфейсом или абстрактным классом), а не реализацией (конкретным классом):

  ILogger {}
 LogManager
{
       ILogger GetLogger() {}
}
 IJobProvider {}
 JobProvider
{
     JobProvider()
     {
         Provider =  DefaultJobProvider();
     }
       IJobProvider Provider { ; ; }
}

Этот паттерн интенсивно используется в .NET Framework (SynchronizationContext.Current, Thread.CurrentThreadThread.CurrentPrincipalHttpContext.Current и т.д.) и применяется для установки нужного окружения для выполнения так называемых «сквозных задач» (cross-cutting concerns), связанных с транзакциями, безопасностью и т.п. Но он может применяться и для других инфраструктурных зависимостей (например, для логгера или безопасности), а также для некоторых типов бизнес-задач.

Этот паттерн не обладает основными недостатками синглтонов, поскольку он оперирует абстракциями, а также поддерживает гибкость, необходимую для юнит-тестирования, и позволяет изменить поведение в зависимости от нужд приложения. Обычно такие зависимости устанавливается в Composition Root приложения и, в случае необходимости, могут содержать реализацию по умолчанию.

Несмотря на эти особенности, Ambient Context обладает и главным недостатком, присущим синглтону и Service Locator-у: неявностью. Чтобы понять, что некоторый класс использует зависимость через Ambient Context нужно проанализировать весь код класса (как мы увидим позднее, есть ряд приемов, чтобы, по крайней мере, уменьшить эту проблему).

Даже при наличии ряда недостатков, Ambient Context с разумным использованием может помочь развязать разные модули друг от друга, не протаскивая слишком большое количество зависимостей через высокоуровневые классы.

Terminology

Before we move on, we need to establish some terminology to communicate more efficiently.

In Dependency Injection’s lingo, we have two important terms: services (or dependencies) and clients.

Services (or Dependencies) are variables, objects, classes, functions, or pretty much any language constructs that provide some sort of functionality to those who consume/depend on them.

Clients are functions or classes that might or might not consume some services.

Note that the classification of whether something is a service or a client depends on the context. That means something could be, at the same time, a service in one context and a client in another.

So, from now on, instead of saying:

“Dependency injection, in its essence, is about parametrizing things that were previously hardcoded in functions/classes, so that we can control these functions/classes to a greater extent”.

We’re going to say:

“Dependency injection, in its essence, is about parametrizing services that were previously hardcoded in clients, so that we can control these clients to a greater extent”.

Top Down vs Bottom Up Approach

Without dependency injection all we can do is write code in a bottom up manner. We start by writing modules that have no dependencies and then write other modules that depend on the ones we have already wrotte, and so on, all the way up to the “surface” modules.

Also, all our tests will be integration tests (which is not a bad thing by itself) due to the lack of mocking capabilities.

But what if we wanted to do the opposite. What if wanted to start developing modules that are closer to the “surface”, and then go all the way to the bottom?

With dependency injection, we can easily do that, as I’m going to show you.

So, the first thing we’ll develop is a run function that is the application’s entry point:

type Dependencies = { // Instead of reading directly from stdin // and writing to stdout, we'll depend on the // read and write abstractions, which gives us // greater flexibility and allows us to mock them read: () => Promise; write: (data: string) => Promise; showTodos: () => Promise; toggleTodo: () => Promise; addTodo: () => Promise; editTodo: () => Promise; deleteTodo: () => Promise; invalidCommand: () => Promise;
};
export const makeRun = ({ read, write, showTodos, toggleTodo, addTodo, editTodo, deleteTodo, invalidCommand, }: Dependencies) => async () => { await write( `Welcome to the To Do App! Commands: "show" - Show todos. "toggle" - Toggle todo. "add" - Add todo. "edit" - Edit todo. "delete" - Delete todo. ` ); while (true) { const command = (await read()).trim(); switch (command) { case Command.Show: showTodos(); break; case Command.Toggle: toggleTodo(); break; case Command.Add: addTodo(); break; case Command.Edit: editTodo(); break; case Command.Delete: deleteTodo(); break; case Command.Quit: return 0; default: invalidCommand(); } } };
export const enum Command { Show = "show", Toggle = "toggle", Add = "add", Edit = "edit", Delete = "delete", Quit = "quit",
}
export type Run = ReturnType;

As read, write and all the invoked routines are extracted as parameters, we don’t need to care about their implementation right now, the only thing we need to know is what we expect them to do, and the “how” can be deferred.

Notice that at this point we can’t run the application because we didn’t write the implementations the run function will use, so how can we be sure that we’re coding the “right thing”?

The answer is tests: by writing unit tests for the run function we can exercise it without running the application, as we can mock all the missing dependencies.

//run.test.ts
// Here we have a sample test
// I won't include others for the sake of
// brevity, but we could easily exercise other
// execution paths by simulating different user
// inputs with the mocked read function.
describe("When initializing", () => { it("Displays the correct message", async () => { const read = jest.fn().mockReturnValueOnce(Promise.resolve(Command.Quit)); const write = jest.fn(); const showTodos = jest.fn(); const toggleTodo = jest.fn(); const addTodo = jest.fn(); const editTodo = jest.fn(); const deleteTodo = jest.fn(); const invalidCommand = jest.fn(); const run = makeRun({ read, ViewTodoDomainMapper: ViewTodoDomainMapperMock, createViewTodosStore, loadTodos, addTodo, deleteTodo, editTodo, invalidCommand, showTodos, toggleTodo, write, }); await run(); expect(write).toHaveBeenCalledTimes(1); expect(write).toHaveBeenNthCalledWith( 1, `Welcome to the To Do App! Commands: "show" - Show todos. "toggle" - Toggle todo. "add" - Add todo. "edit" - Edit todo. "delete" - Delete todo. ` ); });
});

Then, once we’re satisfied with our run implementation and tests, we can move forward to its direct dependencies, for instance, the showTodos routine:

// todo.ts
// Even though we might not need to have our dependencies
// implementations at a certain point in time, we might need
// their interface, which is the case here
export type Todo = { id: string; text: string; status: TodoStatus;
};
export const enum TodoStatus { Complete = "Complete", Incomplete = "Incomplete",
}
// showTodos.ts
import { Todo } from "./todo";
type Dependencies = { write: (data: string) => Promise;
};
export const makeShowTodos = ({ write }: Dependencies) => async (todos: Array) => { if (todos.length === 0) { write("No todos yet!"); return; } const formattedTodos = todos.reduce((string, todo, index) => { const formattedStatus = todo.getStatus() === ViewTodoStatus.Complete ? "[x]" : "[ ]"; const todoNumber = index + 1; const formattedTodo = ${todoNumber}. ${formattedStatus} ${todo.getText()}n; return string + formattedTodo; }, ""); write(formattedTodos); };
export type ShowTodos = ReturnType;
// showTodos.test.ts
import { makeShowTodos } from "./showTodos";
import { TodoStatus } from "./todo";
describe("When there are NO todos", () => { it("Displays the appropriate message", async () => { const write = jest.fn(); const showTodos = makeShowTodos({ write, }); await showTodos([]); expect(write).toHaveBeenCalledTimes(1); expect(write).toHaveBeenNthCalledWith(1, "No todos yet!"); });
});
describe("When there are todos", () => { it("Displays todos formatted correctly", async () => { const write = jest.fn(); const todos: Array = [ { id: "234", status: TodoStatus.Complete, text: "Walk dog", }, { id: "323345", status: TodoStatus.Incomplete, text: "Wash dishes", }, ]; const showTodos = makeShowTodos({ write, }); await showTodos(todos); expect(write).toHaveBeenCalledTimes(1); expect(write).toHaveBeenNthCalledWith( 1, 1. [x] Walk dogn2. [ ] Wash dishesn ); });
});

Then we proceed until all the dependencies are effectively implemented.

The nice thing about this approach is that we can defer thinking about the inner workings of our application and focus on its observable behavior.

One last consideration is that both the top-down and the bottom-up approaches are located on the extremes of a “line”, but there’s a whole gradient between these two extremes. We could start by writing some “surface” modules, then write some “core” modules where they would eventually meet the “middle”.

That is the power that dependency injection gives us, making modules very loosely coupled, we can program to interfaces instead of programming to implementations, which gives us a great deal of flexibility in how we write our software.

Varying Implementations

Imagine that our fictional application has grown and now we have the requirement for our randomNumber to be cryptographically secure, that is, it should be really hard to guess which number it’s going to produce next, based on the previous numbers it has produced.

Thankfully we found a library (also fictional) called secureRandomNumber which exposes a function with the same name (secureRandomNumber) that has the same interface as randomNumber, and does precisely what we need.

This means that we can use secureRandomNumber as a drop-in replacement for randomNumber, as it also generates a random number between 0 and some upper bound that it takes as a parameter, with the upside that it does so in a cryptographically secure way.

So, in practice, in every single place that we called randomNumber, we can just call secureRandomNumber instead.

// Before
const someNumber = randomNumber(10);
// After
const someNumber = secureRandomNumber(10);

But there’s a catch: secureRandomNumber takes considerably more time to run than randomNumber, so we want to keep using randomNumber in non-productive environments and only use secureRandomNumber in production (by the way, I’m not advocating this).

This means that when NODE_ENV !== "production", we want all modules that use randomNumber to keep using it, but when NODE_ENV === "production", they will use secureRandomNumber instead.

One thing we could do is to change the implementation of randomNumber based on which environment we’re on:

// randomNumber.ts
import { secureRandomNumber } from "secureRandomNumber";
export const makeRandomNumber = ( // From now on, we'll use the // RandomGenerator type inline instead // of having is defined as a named type, // for brevity randomGenerator: () => number, secureRandomNumber: (max: number) => number ) => (max: number) => { if (process.NODE_ENV !== "production") { return Math.floor(randomGenerator() * (max + 1)); } return secureRandomNumber(max); };
export const randomNumber = makeRandomNumber(Math.random, secureRandomNumber);

There are lots of problems with this approach, but right now I want to draw your attention to just one:

makeRandomNumber is doing too many things at once.

At first, makeRandomNumber‘s responsibility was to create a function that generates a random number, and now it still does that, but it also selects which algorithm is going to be used to generate the random number.

We even had to change its interface to accommodate that requirement, which alone, would break all its unit tests.

So, instead of doing this, let’s consider another approach, one that leverages dependency injection.

First, we will keep the different implementations of randomNumber separate, which in this case will be done using different files for each implementation.

As secureRandomNumber comes from a library, we only need to create a new file for our former randomNumber implementation, which is now going to be called fastRandomNumber.

// fastRandomNumber.ts
// This file holds our original
// randomNumber implementation
export const makeFastRandomNumber = (randomGenerator: () => number) => (max: number) => { return Math.floor(randomGenerator() * (max + 1)); };
export const fastRandomNumber = makeFastRandomNumber(Math.random);

Then, in the randomNumber.ts file, we only select which implementation is going to be used for the randomNumber service.

// randomNumber.ts
// This file only exports
// the selected random number function
import { fastRandomNumber } from "./fastRandomNumber";
import { secureRandomNumber } from "secureRandomNumber";
export const randomNumber = process.env.NODE_ENV !== "production" ? fastRandomNumber : secureRandomNumber;

Much cleaner right? And now we won’t be breaking any unit tests.

Дополнительно:  Синий экран смерти

Also, let’s take a look at randomNumberList, which uses randomNumber:

// randomNumberList.ts
import { randomNumber } from "./randomNumber";
export const makeRandomNumberList = (randomNumber: (max: number) => number) => (max: number, length: number): number => { return Array(length) .fill(null) .map(() => randomNumber(max)); };
// As we currently only have a single randomNumberList
// implementation, we can keep both its implementation
// and service creation in the same file
export const randomNumberList = makeRandomNumberList(randomNumber);

As we can see above, consumers of randomNumber can remain completely unaware of which is the actual implementation they are using for it, as they all import it from the same place, and all implementations adhere to the same interface.

In general, whenever we need to use different implementations of a certain service, it’s better to use the strategy pattern. Instead of having the service itself behave differently according to some parameter, we create different implementations of it and then select the appropriate one to pass to the client.

Mock Implementations In Development

Another interesting usage of this technique is when we want to mock external systems (like external APIs) not only while testing but during development as well.

// apiFetchUser.ts
export const makeApiFetchUser = (apiBaseUrl: string) => async () => { const response = await fetch(${apiBaseUrl}/users); const data = await response.json(); return data;
};
// Using window fetch
export const apiFetchUser = makeApiFetchUser(process.env.API_BASE_URL);
// fetchUser.ts
// This is where the parts of the application that
// use fetchUser import it from.
import { apiFetchUser } from "./apiFetchUser";
export const fetchUser = apiFetchUser;

Now, if we want a specific mocked implementation, either because the API is still not ready to be used, or because we want a specific output to develop some feature or reproduce some bug, we can replace the original implementation with a mocked one:

// inMemoryFetchUser.ts
// In this case, as inMemoryFetchUser
// has no dependencies, we don't need a
// factory function
export const inMemoryFetchUser = () => { return Promise.resolve([ { id: "1", name: "John", }, { id: "2", name: "Fred", }, ]);
};
// fetchUser.ts
// This is where parts of the application that
// use fetchUser import it from.
import { restFetchUser } from "./restFetchUser";
import { inMemoryFetchUser } from "./inMemoryFetchUser";
// We comment this line where we were
// using the original implementation
// export const fetchUser = restFetchUser; void): void => { const intervalInMs = 5000; setInterval(() => { // Data fetching logic callback(data); }, intervalInMs);
//);

We’re polling every 5 seconds, but this value is hardcoded, which means that anytime we want to change it we need to alter the code, and then, in most cases, open a PR, get it reviewed, approved, and merged.

Also, we might want to have different intervals for different environments.

Maybe, we want this value to be higher in production to avoid straining the servers, but somewhat shorter in development to give us quicker feedback and even shorter in local development.

So, to achieve the flexibility we need, we extract this value as an environment variable:

export const poll = (callback: (data: Data)): void => { setInterval(() => { // Data fetching logic callback(data); }, process.env.POLLING_TIME_INTERVAL_IN_MS);
}

Now we want to do some integration testing with this function and see how it behaves with a specific time interval, so we create a .env.test where we set POLLING_TIME_INTERVAL_IN_MS to the value we want and then make sure we’re loading environment variables from .env.test.

Then, we end up with two problems:

  • What if we want to use different time intervals for different tests?
  • We have to make sure the.env.test is always up to date with the other environment variables.

These problems arise because poll knows where the time interval is coming from, but in the end, it doesn’t care where it comes from, it just needs a value for the time interval, so why not extract it as a dependency?

export const makePoll = (pollingTimeIntervalInMs: number) => (): void => { setInterval(() => { // Data fetching logic callback(data); }, pollingTimeInterval);
};
export const poll = makePoll(process.env.POLLING_TIME_INTERVAL);

Now, when testing, we can inject any pollingTimeIntervalInMs we want and we don’t even need a .env.test anymore.

Also, another benefit is that in the first implementation, where we had process.env.POLLING_TIME_INTERVAL buried deep inside poll, it is not evident that poll is relying on an environment variable, so to discover that we need to look at its implementation.

In our refactored example, we moved the dependency to makePoll‘s interface making the dependency relation explicit.

The moral of the story is: treat configuration values as any other dependency and extract them as parameters so the services that use them don’t need to know where they’re coming from. This facilitates testing and allows us to change where we’re pulling these values from (like reading a file or calling an API asynchronously) without changing the services’ implementation.

Стабильные и изменчивые зависимости

У каждого из нас есть интуитивное представление о том, что является зависимостью, и что ею не является. Так, например, с параноидальной точки зрения любая сущность, используемая нашим классом, является зависимостью, включая примитивные типы языка программирования и другие типы стандартной библиотеки. С другой стороны, очевидно, что даже в стандартной библиотеке есть классы, использование которых напрямую может «связать» наш класс слишком тесно с некоторым окружением и сделает невозможным, например, модульное тестирование.

Марк Cиман (Mark Seeman) в своей книге “Dependency Injection in .NET” именно по этой причине выделяет два типа зависимостей: с одной стороны у нас есть стабильные зависимости (stable dependencies), «абстрагироваться» от которых нет особого смысла, поскольку они доступны «из коробки», являются стандартом «де факто» в вашей команде, и их поведение не меняется в зависимости от состояния окружения.

Кроме этого, есть изменчивые зависимости (volatile dependencies), реализация (или даже публичный интерфейс) которых может измениться в будущем, либо их поведение может измениться «самостоятельно» без нашего ведома, поэтому их нужно выделить в отдельную абстракцию и ограничить их использование. К такому классу зависимостей относятся библиотеки сторонних разработчиков, собственный код, область применения которого хорошо бы ограничить. К этому же типу зависимости относятся зависимости, завязанные на текущее окружение, такие как файлы, сокеты, базы данных и так далее. И хотя интерфейс таких зависимостей едва ли изменится в будущем, их «изменчивость» проявляется в том, что их поведение может измениться без изменения пользовательского кода.

На самом деле, ограничение использования любой зависимости является идеальным способом борьбы с изменениями. Ведь чем в меньшем количестве мест в вашем коде используется некоторый класс или интерфейс, тем меньше стоимость его изменения или ошибки в его реализации. Если вы считаете, что нечто может измениться в будущем, то сделайте это деталью реализации, сведя, таким образом, область изменений к минимуму.

И хотя названия этих двух типов зависимостей с первого взгляда могут показаться не вполне точными, лично меня эти термины вполне устраивают. Главное, чтобы это разделение было у нас в голове: есть зависимости, от которых нужно абстрагироваться, а есть такие, которые можно использовать напрямую, и в этом нет ничего страшного!

Кроме того, одну и ту же зависимость (например, все тот же FileStream) можно в одном контексте рассматривать как стабильную, а в другом – как изменчивую. С другой стороны, само использование класса FileStream говорит о некотором «персистентном» хранилище или чем-то подобным. Возможно, разумнее будет абстрагироваться не просто от файловых операций, а выделить некоторую бизнес-сущность, типа IConfigurationLoader, и «протаскивать» уже ее, а не низкоуровневые сущности, типа IStream или собственного IFileStream.

Interlude

Congratulations, if you’ve made it this far, this means that you already understand what dependency injection is, how to use it in practice, and some of its most common use cases.

From this point forward, we’ll talk about how to make working with dependency injection more manageable and explore some additional techniques and use cases.

Composition Root (AKA Container)

So far, for each service we have been using, there’s a file that creates the service and then exports it (which might or might not contain the service’s implementation itself), the file from which all clients that use the service import it from.

Now, I want to show you a different approach, where, instead of having a separate file for the creation of each service, we’ll have a single file where we centralize the creation of all services.

The reason we do that is two-fold:

  • To separate concerns: so the concern of defining the implementation of a service is separated from the concern of building it.
  • To enable us to do things more easily: like integration testing, or things that wouldn’t be possible otherwise, like dealing with cyclic dependencies and building services asynchronously.

To see this process in practice, let’s go back to our first example, where we were generating random numbers:

// randomNumber.ts
// Previously, this is the file where we
// selected the appropriate randomNumber implementation,
// and then exported it as the randomNumber service.
// Now, the only thing we'll do here, is defined
// the RandomNumber interface, to which its implementations
// will adhere.
export type RandomNumber = (max: number) => number;
// fastRandomNumber.ts
import { RandomNumber } from "./randomNumber";
export const makeFastRandomNumber = (randomGenerator: () => number): RandomNumber => (max: number): number => { return Math.floor(randomGenerator() * (max + 1)); };
// randomNumberList.ts
import { RandomNumber } from "./randomNumber";
export const makeRandomNumberList = (randomNumber: RandomNumber) => (max: number, length: number): Array<number> => { return Array(length) .fill(null) .map(() => randomNumber(max)); };
// Notice that we are not creating the randomNumberList
// service here anymore

Then, we’ll have a single file that will take care of “plugging” all dependencies together:

// container.ts
import { secureRandomNumber } from "secureRandomNumber";
import { makeFastRandomNumber } from "./fastRandomNumber";
import { makeRandomNumberList } from "./randomNumberList";
const randomGenerator = Math.random;
const fastRandomNumber = makeFastRandomNumber(randomGenerator);
const randomNumber = process.env.NODE_ENV === "production" ? secureRandomNumber : fastRandomNumber;
const randomNumberList = makeRandomNumberList(randomNumber);
export const container = { randomNumber, randomNumberList,
};
export type Container = typeof container;

This file where we centralize the creation of all services is called composition root or container.

Structurally speaking, there’s a crucial difference between having service creation happen in a single place and having it happen all over the application.

When each service is created in its file, and, especially when this file also contains the service’s implementation, services know where their dependencies are coming from because they import them directly.

For example, previously in our examples, randomNumber imported its implementations directly from fastRandomNumber.ts and the secureRandomNumber lib, and randomNumberList, which also resided in the same file as its implementation, imported randomNumber directly from randomNumber.ts.

Now, when services are created in a single place, this is the only place that knows about all services/dependencies, and where they’re coming from.

As you can see in our current example, the only thing that files (aside from the composition root) import from one another, are types/interfaces, and nothing else, which makes them as decoupled as they can get.

In JS, this might not seem like a huge benefit, given that it is an interpreted language, and we don’t have things like static/dynamic linking, in which case this structuring allows us to reduce compilation times and load code as plugins, but it does open the door for some important techniques surrounding DI.

Going back to the container itself, to compose services there are certain constraints that we must be mindful of, regarding the order we create them.

When we consider the services’ inter-dependency relation, that is, who depends on who, they form a dependency graph:

Что такое composition root

The arrows on the image represent the “depends on” relation.

Services must be created in what we call a reverse topological order, that is, first we must create all the dependencies that have no dependencies themselves, then we proceed to the ones that only depend on the already created ones, and so on until there is no service left to be created.

When each service was created in its file, we didn’t have to worry about the order in which we created them, because the “import system” took care of that for us.

After creating all the services, they are now ready to be used, so it begs the question, how do we use them?

Whenever we have a composition root, we’re splitting our application into two phases:

  1. A boot phase
  2. A run phase

The boot phase is like a “runtime build” phase, where we assemble our application by creating all the services and plugging them together.

Then, at the run phase, we start the application using the services from the container.

Going back to the PC analogy, it’s as if each service was a PC part that was manufactured in isolation, and then there’s an assembly line (which corresponds to the composition root) where we assemble all the individual parts into a PC.

It’s only when all parts are composed together, we can turn on the PC.

So, when our container is ready to be used, we must call it in the application’s entry point.

Depending on the kind of application we’re dealing with and, especially, the framework we might be using, what’s considered the application’s entry point may vary, but in the simplest case, it’d be the index.ts that is called when the application starts:

// index.ts
import { container } from "./container";
const main = () => { // Do stuff with container const randomNumberList = container.randomNumberList; // Reads max and length from command line const max = Number(process.argv[2]); const length = Number(process.argv[3]); console.log(randomNumberList(max, length)); return 0;
};
main();

Of course, the idea is to have only the bare minimum logic necessary to kickstart the application in our entry point, so that all the complexity gets isolated inside services.

Дополнительно:  How to Root Galaxy S5 SM-G900H (International Exynos)

Приложите все усилия для того, чтобы вы управляли зависимостями в приложении, а не зависимости управляли вами.
Мнение автора статьи по поводу зависимостей

Основной способ борьбы со сложностью сводится к тому, чтобы в один момент времени мы могли сосредоточиться на минимальном количестве сущностей или абстракций. В основном этот принцип исходит из двух предпосылок: во-первых, средний человек не в состоянии держать в голове одновременно слишком большое количество понятий (обычно говорится о цифре семь, плюс минус две), и при увеличении их количества приходится сохранять избыточную информацию и анализировать каждый из ее кусков повторно. Во-вторых, при увеличении количества сущностей, которыми мы оперируем в один момент времени, увеличивается и суммарная сложность их поведения: достаточно попытаться объединить в одном месте кэширование и обычную бизнес-логику, чтобы понять, что это значит.

Проблема вся в том, что при увеличении количества понятий (сущностей, абстракций) в одном месте сложность растет экспоненциально, что приводит к невозможности удерживать задачу в голове даже при минимальном ее усложнение. Как следствие, это приводит к появлению ненужных ошибок, неучтенных граничных условий и проблем сопровождения, поскольку кроме автора уже никто не в состоянии оценить всю глубину глубин и понять, что в этом коде происходит.

Все принципы ООП, включая Принцип Единой Ответственности, понятия связности и связанности (cohesion и coupling), и многие другие, призваны бороться с неотъемлемой сложностью (essential complexity) нашей бизнес-области и сводить случайную сложность (accidental complexity) к минимуму. Все наши продуманные абстракции, хитроумные паттерны и высокоуровневые языки программирования призваны акцентировать внимание на естественной сложности задачи, скрывая несущественные подробности.

Automatic DI Containers

This approach where we compose our dependencies ourselves in the composition root is called manual DI or pure DI, but as the number of dependencies grows so does the complexity of maintaining the composition root and making sure that we’re creating dependencies in the right order.

To solve this problem we have automatic DI containers, which take care of automatically creating all dependencies in the right order for us.

DIY Automatic DI Container

There are already off-the-shelf libraries that provide automatic DI containers, but before we look at that, I’d like to show you how we could build an automatic DI container ourselves.

// randomNumber.ts
export type RandomNumber = (max: number) => number;
// fastRandomNumber.ts
import { RandomNumber } from "./randomNumber";
// To be able to construct dependencies
// automatically, we first need to
// start using named arguments to pass
// dependencies to services, that is,
// we'll wrap dependencies in an object
type Dependencies = { randomGenerator: () => number;
};
export const makeFastRandomNumber = ({ randomGenerator }: Dependencies) => (max: number): number => { return Math.floor(randomGenerator() * (max + 1)); };
// randomNumberList.ts
import { RandomNumber } from "./randomNumber";
type Dependencies = { randomNumber: RandomNumber;
};
export const makeRandomNumberList = ({ randomNumber }: Dependencies) => (max: number, length: number): Array<number> => { return Array(length) .fill(null) .map(() => randomNumber(max)); };
// container.ts
import { makeFastRandomNumber } from "./fastRandomNumber";
import { makeRandomNumberList } from "./randomNumberList";
import { secureRandomNumber } from "secureRandomNumber";
// We first "declare" what our services are
// and their respective factories
const dependenciesFactories = { randomNumber: process.env.NODE_ENV !== "production" ? makeFastRandomNumber : //For this to work, // we'll need to wrap this in a factory () => secureRandomNumber, randomNumberList: makeRandomNumberList, randomGenerator: () => Math.random,
};
type DependenciesFactories = typeof dependenciesFactories;
// Some type magic to type the container
export type Container = { [Key in DependenciesFactories]: ReturnValue<DependenciesFactories[Key]>;
};
export const container = {} as Container;
Object.entries(dependenciesFactories).forEach(([dependencyName, factory]) => { // This is why we need to wrap our dependencies in // an object, because then we're able to pass // the entire container to factories, and // even though we're both passing more dependencies // than needed and, dependencies that at some // point in time might be undefined, it doesn't matter. return Object.defineProperty(container, dependencyName, { // We're using a getter here to avoid // executing the factory right away, which // would break due to some dependencies // being undefined by the time the factory // is executed. // This way, factories are only //called when the whole container // is already set up, and then, accessing // some service triggers the recursive creation // of all its dependencies get: () => factory(container), });
});

This DIY automatic DI container construction is somewhat convoluted, but worry not, for I only included it in this article to give you an idea of how automatic DI containers work under the hood, but you don’t need to fully understand it to use it, especially because there are libraries that take care of that for us.

The main point here is that we don’t have to worry about the order we create our dependencies anymore as our automatic DI container will take care of everything. The only thing we need to do is to register dependencies with the appropriate resolver.

Now, let me show you a production-grade automatic DI container.

Awilix

There are a few libraries out there that provide us with an automatic DI container, and in this post, we’ll use Awilix which is my go-to DI container, given how powerful and easy to use it is.

// randomNumber.ts
export type RandomNumber = (max: number) => number;
// fastRandomNumber.ts
type Dependencies = { randomGenerator: () => number;
};
export const makeFastRandomNumber = ({ randomGenerator }: Dependencies) => (max: number): number => { return Math.floor(randomGenerator() * (max + 1)); };
// randomNumberList.ts
import { RandomNumber } from "./randomNumber";
type Dependencies = { randomNumber: RandomNumber;
};
export const makeRandomNumberList = ({ randomNumber }: Dependencies) => (max: number, length: number): Array<number> => { return Array(length) .fill(null) .map(() => randomNumber(max)); };
// For services where we expect to have
// a single implementation, we can also
// export this type which is going to be useful
// for other services
export type RandomNumberList = ReturnType<typeof makeRandomNumberList>;
// container.ts
import { asValue, asFunction, asClass, InjectionMode, createContainer as createContainerBuilder,
} from "awilix";
import { AwilixUtils } from "./utils/awilix";
// Here we'll define our dependencies
// and wrap them with the appropriate resolver
// so that they can be instantiated
// automatically by Awilix.
//
// Resolvers are used to tell Awilix
// how a given dependency must be resolved,
// whether it is created using a factory function,
// in which case we use the asFunction resolver,
// or whether it is an instance of a class, in which case
// the asClass is the correct resolver, or even
// whether it is a value that is ready to be used as is,
// that is, one that doesn't need to be constructed,
// in which case we use the asValue resolver.
export const dependenciesResolvers = { randomGenerator: asValue(Math.random()), // This .asSingleton() means that we'll be using // a single instance of this dependency // throughout the whole application, which // is how we've been using dependency injection // so far. // There are other possible LIFETIMES, // but this goes out of the scope of this // article randomNumber: process.env.NODE_ENV === "production" ? asFunction(makeSecureRandomNumber).singleton() : asValue(fastRandomNumber), randomNumberList: asFunction(makeRandomNumberList),
};
// Everything below here is boilerplate
// This is the awilix container builder,
// where we register the dependencies resolvers and which
// will take care of actually plugging everything
// together
const containerBuilder = createContainerBuilder({ injectionMode: InjectionMode.PROXY,
});
containerBuilder.register(dependencies);
// The actual container that contains all
// our instantiated dependencies
export const container = containerBuilder.cradle;
// utils/awilix.ts
export namespace AwilixUtils { // Some type magic to make sure our container // is typed correctly type ExtractResolverType = T extends Resolver ? U : T extends BuildResolver ? U : T extends DisposableResolver ? U : never; export type Container = { [Key in keyof Dependencies]: ExtractResolverType; };
}

Let’s take a more in-depth look at Awilix:

Ultimately, we want to end up with a container that has all our services instantiated. To do that, we need to tell Awilix two things:

  1. The dependencies we have.
  2. How to create them.

This is done by registering dependencies and their resolvers in the containerBuilder, where each “kind” of dependency must be wrapped with the appropriate resolver.

If we’re injecting something directly, that is, that doesn’t need any kind of construction/creation, we use the asValue resolver.

If what we’re injecting is created using a factory function (which we’ve been using so far), we then use the asFunction resolver.

Lastly, if our dependency is created using a class, we use the asClass resolver.

Awilix uses JS Proxy to do its magic, so whenever we access some service in the container, it intercepts it and recursively creates all dependencies along the way needed to create the service we’re accessing.

One of the things I really like about Awilix is how unintrusive it is. To use it in our application, we didn’t have to change anything in the code. The only place that changed was the container itself.

Everything else remains the same: our tests, our services’ implementations, and how we call the container in our application’s entry point.

Управление зависимостями

Тепляков Сергей Владимирович

Composition Root

Любое приложение содержит точку входа, с которой начинается его исполнение. В идеальном случае эта точка содержит всего несколько строк кода, которые сводятся к созданию одного (или нескольких) экземпляров самых высокоуровневых классов, представляющих основную логику приложения. Именно здесь нам придется решить, какие абстракции требуются нашим модулям верхнего уровня, и именно точка входа приложения является идеальным местом для разрешения всех зависимостей.

Что такое composition root

У разных типов приложений точка входа выглядит по-разному: для консольного приложения это метод Main, для WPF приложения – App.xaml, для Web-приложения – Global.asax, для NT-сервиса – класс-наследникServiceBaseи т.д. Независимо от типа приложения, именно точка входа приложения является «точкой невозврата», когда откладывать решение о зависимостях на потом уже невозможно.

Независимо от того, какая стратегия конфигурирования выбрана (код, xml-конфигурация, convention-based), именно в Composition Root должна располагаться логика конфигурирования приложения. И, по сути, это должно быть единственным местом использования самого контейнера:

 container = BuildContainer(); rootModule = container.Resolve<RootModule>();
container.Release(rootModule);

В реальном приложении процесс конфигурирования контейнера будет несколько сложнее. Даже средней сложности приложение может содержать десятки, если не сотни, зависимостей, конфигурирование которых в одном месте сделает Composition Root слишком сложным. В этом плане могут помочь DI-контейнеры, большая часть которых поддерживают идею модульности (Modules в autofac, Installers в Windsor и т.д.). Использование модулей позволит разбить крупное приложение на высокоуровневые компоненты, каждый из которых будет уже правильно сконфигурирован.

Несмотря на то, что конфигурация контейнера может быть размазана по нескольким модулям, его использование для получения зависимостей должно быть ограниченным.

Program to an interface not an implementation

Before implementing the Application component, I’m going to define the interfaces for the ConsoleInputListener, ConsolePrinter and RomanianTranslator. I’m going to call them InputListener, Printer and Translator for simplicity.

The reason I’m defining interfaces* is because I want to be able to swap the objects that the Application class references. In Python variables don’t constrain me to any type, but if I’m going to implement other objects, I’d like to have a template so it will help me reduce the number of mistakes that I can make.

Python doesn’t have support for interfaces so I’m going to use abstract classes:

 "print is not implemented" "get_input is not implemented!" "translate must be implemented!"

Enter fullscreen mode

Exit fullscreen mode

Every class that extends my abstract classes must implement it’s abstract methods:

 

Enter fullscreen mode

Exit fullscreen mode

The Message class, for the sake of completeness only holds a string.

 

Enter fullscreen mode

Exit fullscreen mode

And finally, the Application class will glue all the components together and instantiate them:

 

Enter fullscreen mode

Exit fullscreen mode

The main method will just run the Application:

 

Enter fullscreen mode

Exit fullscreen mode

Running the application would output:

 starting application. < hello Dev! > salut Dev! 

Enter fullscreen mode

Exit fullscreen mode


You could modify the application code to take in two parameters: translator and printer use the arguments to instantiate the correct translator and printer without needing to change the other classes. You can add as many printers, translators and input listeners as you wish. That’s a huge benefit.

If you were to inline all the code in a single class, adding more translations and more printing options would have been very painful and frustrating.

I hope my article gave you some insight into the Composition Root pattern, if I made any mistake please feel free to correct me. 🙂

The full code is on my Github.

Thanks for reading!



Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы
то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских
прав.

Оцените статью
Master Hi-technology