Приложение передачи показаний водосчётчиков готово, но я не спешу публиковать его на сервер, потому что не доволен реализацией. Отдельные части приложения, да и архитектура в целом оставляют желать лучшего. Самое время это исправить. В первую очередь я хочу изменить зависимости между компонентами так, чтобы это в бОльшей степени соответствовало принципам чистой архитектуры. А для большей наглядности решено было нарисовать диаграмму зависимостей:

- Saures API и TomRC API зависят от предметной области и ядра приложения, что не есть хорошо, поскольку с концептуальной точки зрения обе библиотеки предоставляют доступ к соответсnвующему API и на этом их ответственность заканчивается. Этим библиотекам не важно кто и как будет их использовать, важно что логика вызывающих приложений не должна просачиваться внутрь.
- Код инъекций зависимостей «размазан» ровным слоем по всем библиотекам. Да, зависимость от интерфейса, а не от конкретной реализации, но это всё равно не хорошо.
Произведу рефакторинг компонентов таким образом, чтобы в конечном итоге получилось вот так:

Начну с библиотек Saures API и TomRC API — уберу ссылку на проект Core и NuGet пакет Microsoft.Extensions.DependencyInjection.Abstractions, а также выделю классы предметной области (Domain). В результате проекты выглядят вот так:

Минимум зависимостей и единственная ответственность — то что надо! Однако нужно как-то обеспечить связь Saures API и TomRC API с интерфейсами объявленными в ядре приложения. Для этого создам проект Infrastructure. Задача этого слоя — быть мостом между ядром приложения и внешними зависимостями. Тут не должно быть сложных алгоритмов или реализаций некоторых сервисов, а лишь посреднические услуги:

Наконец последний проект WaterMeterAutomation переименованный в EntryPoint. Задачи у точки входа простые:
- загрузить конфигурацию приложения.
- обеспечить логирование необработанных исключений
- настроить необходимые сервисы
- запустить выполнение полезной работы.
В процессе реализации решено было отказаться от использования универсального узла .NET, поскольку он больше подходит для длительно работающих приложений, типа win-сервисов, а это не тот случай. Да и интеграция с расширениями от MS тут тоже не требуется. Так что я взял в качестве основы реализацию метода Main со страницы NLog — Getting started with .NET Core 2 Console application. В результате получилось вот так:
internal class Program { private static async Task Main() { var logger = LogManager.GetCurrentClassLogger(); try { var configuration = CreateConfiguration(); ConfigureMailTarget(configuration); var container = new StashboxContainer() .RegisterInstance(configuration) .ComposeAssembly(Assembly.GetExecutingAssembly()); #if DEBUG container.Validate(); #endif var actor = container.Resolve<MainInteractionService>(); await actor.SendNewWaterMetersValuesFromSourceToConsumer(); } catch (Exception ex) { logger.Error(ex, "Stopped program because of exception"); throw; } finally { // Ensure to flush and stop internal timers/threads before application-exit (Avoid segmentation fault on Linux) LogManager.Shutdown(); } } private static IConfiguration CreateConfiguration() { return new ConfigurationBuilder() .AddJsonFile("appsettings.json") .AddEnvironmentVariables("wma_") .Build(); } private static void ConfigureMailTarget(IConfiguration configuration) { var loggerCredentials = configuration.GetSection("Smtp").Get<LoggerCredentials>(); var config = LogManager.Configuration; var mailTarget = config.FindTargetByName<MailTarget>("mail"); mailTarget.SmtpAuthentication = SmtpAuthenticationMode.Basic; mailTarget.SmtpUserName = loggerCredentials.Login; mailTarget.SmtpPassword = loggerCredentials.Password; } private record LoggerCredentials { public string Login { get; init; } public string Password { get; init; } } }
Вторым нововведением стало использование контейнера внедрения зависимостей StashBox, По сравнению с решением от MS этот контейнер обладает куда большим набором возможностей, например я воспользовался регистрацией зависимостей в классах с реализацией интерфейса ICompositionRoot:
Это позволяет регистрировать все зависимости приложения вызвав единственный метод ComposeAssembly
с указанием сборки.
internal sealed class CompositionRootConfiguration : ICompositionRoot { private readonly IConfiguration _configuration; public CompositionRootConfiguration(IConfiguration configuration) { _configuration = configuration; } public void Compose(IStashboxContainer container) { container.Register<SauresConfiguration>(o => o.WithFactory(() => _configuration.GetSection("SauresApi").Get<SauresConfiguration>()) .WithLifetime(Lifetimes.Singleton)); container.Register<TomRcConfiguration>(o => o.WithFactory(() => _configuration.GetSection("TomRcApi").Get<TomRcConfiguration>()) .WithLifetime(Lifetimes.Singleton)); container.Register<MainInteractionServiceConfiguration>(o => o.WithFactory(() => _configuration.GetSection("MainInteractionService").Get<MainInteractionServiceConfiguration>()) .WithLifetime(Lifetimes.Singleton)); } } internal sealed class CompositionRootCore : ICompositionRoot { public void Compose(IStashboxContainer container) { container.Register<MainInteractionService>(); } } internal class CompositionRootLoggerAndNotification : ICompositionRoot { public void Compose(IStashboxContainer container) { container.Register<ILogger>(o => o.WithFactory(() => LogManager.GetLogger("Global"))); container.RegisterSingleton<INotificationService, NotificationService>(); } } internal sealed class CompositionRootSauresApi : ICompositionRoot { private readonly SauresConfiguration _sauresConfiguration; public CompositionRootSauresApi(SauresConfiguration sauresConfiguration) { _sauresConfiguration = sauresConfiguration; } public void Compose(IStashboxContainer container) { container.Register<Api>(o => o.WithFactory(() => new Api(_sauresConfiguration.Host)) .WithLifetime(Lifetimes.Singleton)); container.Register<IWaterMetersDataSourceService, WaterMetersDataSourceService>(o => o.WithFactory<Api>(api => new WaterMetersDataSourceService(api, _sauresConfiguration.Login, _sauresConfiguration.Password)) .WithLifetime(Lifetimes.Singleton)); } } internal class CompositionRootTomRcApi : ICompositionRoot { private readonly TomRcConfiguration _tomRcConfiguration; public CompositionRootTomRcApi(TomRcConfiguration tomRcConfiguration) { _tomRcConfiguration = tomRcConfiguration; } public void Compose(IStashboxContainer container) { container.Register<Api>(o => o.WithFactory(() => new Api(_tomRcConfiguration.Host)) .WithLifetime(Lifetimes.Singleton)); container.RegisterSingleton<DocumentParser>(); container.Register<IWaterMetersDataConsumerService, WaterMetersDataConsumerService>(o => o.WithFactory<Api, DocumentParser>((api, parser) => new WaterMetersDataConsumerService(api, _tomRcConfiguration.Login, _tomRcConfiguration.Password, parser)) .WithLifetime(Lifetimes.Singleton)); } }
И немного проще стали классы конфигурации для SauresAPI и TomRcAPI:
internal abstract record ApiConfiguration { public string Host { get; init; } public string Login { get; init; } public string Password { get; init; } } internal record SauresConfiguration : ApiConfiguration; internal record TomRcConfiguration : ApiConfiguration;

На этом рефакторинг решения завершён. Однако напоследок хочу поделиться особенностями отправки эл. писем из приложения. В настоящее время большинство почтовых служб вводят дополнительные проверки при авторизации и зачастую закрывают авторизацию только по имени пользователя и паролю. Yandex предлагает элегантное решение: Пароли приложений. Суть проста: для некоторых служб создаётся отдельная связка имя пользователя \ пароль для простой авторизации. В это же время для основного аккаунта дополнительные средства безопасности и в том числе двухфакторная авторизация продолжают работать. Удобно? — безусловно.