В предыдущей статье мы максимально подробно разобрали, что такое интерфейсы в языке программирования C# и особенности их применения. Здесь же мы посмотрим как использовать интерфейсы C# на практике при проектировании приложения, unit тестировании и внедрении DI контейнеров.
Подпишись на группу Вконтакте и Телеграм-канал. Там еще больше полезного контента для программистов.
А на YouTube-канале ты найдешь обучающие видео по программированию. Подписывайся!
Что такое интерфейс?
Для тех, кто еще не в курсе следует очень коротко дать определение этого понятия:
Интерфейс — это поименованный набор сигнатур методов.
То есть, у нас есть контракт (ну или соглашение), что должен уметь делать класс, а вот как это будет реализовано – пока не известно.
Где применяются интерфейсы?
Современные подходы к разработке приложений очень тесно завязаны на использование интерфейсов. Это позволяет создавать слабосвязанные и более гибкие приложения (не путать связность, со связанностью). Но где и для чего именно могут быть полезны интерфейсы?
- Проектирование приложения
- Работа в команде
- Разделение на слои
- Реализация полиморфной связи
- Использование DI контейнера
- Mock тестирование
Это далеко не полный список, но здесь я перечислил наиболее важные темы, которые мы и рассмотрим далее.
Проектирование приложения
Важнейшим этапом разработки приложения является его проектирование – мы определяем какие сущности важны для нашей предметной области и обдумываем, как они должны между собой взаимодействовать.
Иногда по ошибке можно скатиться в лишние подробности и детали реализации, при этом упустив общую картину. Чтобы такого не допускать, я рекомендую начинать разработку архитектуры приложения именно с интерфейсов.
Давайте для примера с нуля напишем какое-нибудь простенькое приложение. Например, книжный магазин. Естественно, это только PoC-проект, которому далеко даже до прототипа, однако, он позволит нам рассмотреть все особенности применения интерфейсов. Весь исходный код проекта доступен в GitHub.
Начнем с проектирования предметной области:
- Книга – мы знаем ее название, автора и цену. Сама по себе книга ничего не умеет делать.
- Магазин – имеет название и физический адрес. Мы должны уметь пополнять содержимое магазина новыми книгами, также покупатель может посмотреть список всех книг, ну и конечно же магазин может продать книгу.
- Чек – чтобы зафиксировать факт покупки книги нам необходимо знать какая книга была куплена, в каком магазине и когда.
Примерно такие требования можно собрать на этапе первого обсуждения ТЗ с заказчиком. Таким образом у нас получается первый набросок предметной области:

Хочу еще раз подчеркнуть, что это МАКСМИАЛЬНО упрощенный прототип, с огромным количеством допущений и возможностей для улучшения. Тут нет соответствие первой нормальной форме, и продать за один раз мы можем только одну книгу, и цена привязана к книге, и UML-диаграммы я не использую, но цель проекта не в том, чтобы написать хорошее приложение, а продемонстрировать принципы работы с интерфейсами.
Вот, кстати, это и будет домашним заданием. Вам будет нужно будет самостоятельно разработать приложение с такой же предметной областью, но лишенное недостатков проектирования. Едем дальше.
Теперь, опираясь на эту предметную область мы можем определить необходимые интерфейсы.
BookShop/BookShop.Bll
namespace BookShop.Bll { public interface IBook { string Name { get; set; } string Author { get; set; } int Price { get; set;} } }
using System.Collections.Generic; namespace BookShop.Bll { public interface IShop { string Name { get; set; } string Address { get; set; } void Add(IBook book); IEnumerable GetAllBooks(); ICheck Sell(IBook book); } }
using System; namespace BookShop.Bll { public interface ICheck { IShop Shop { get; set;} IBook Book { get; set;} DateTime DateTime { get; set;} } }
Определение интерфейсов занимает значительно меньше времени по сравнению с написанием классов, позволяет увидеть соотношение между сущностями и операции над ними, защищает от искушения углубиться в детали реализации и при всем этом изначально закладывает возможность для более гибкого приложения. Но и это еще не всё. Данный подход приводит к следующему важному преимуществу интерфейсов
Работа в команде
Сейчас практически ни один более-менее серьезный проект не делается в одиночку. Такое конечно бывает, но явление редкое. Поэтому начало разработки приложения через определение интерфейсов приносит еще один важный бонус — удобство при командной разработке.
Архитектору достаточно определить основные интерфейсы и их взаимодействие, а их реализацию в конкретных классах поручить разным разработчикам. При этом они не будут мешать или ждать друг друга, ведь основной контракт уже проработан, а внутренние детали чужого кода не так важны.
В нашем примере это могут быть три разных программиста, которые будут каждый реализовывать по одному интерфейсу IBook
, ICheck
, IShop
в соответствующие классы. В результате их работы должны получиться приблизительно следующие классы:
BookShop/BookShop.Bll
using System; namespace BookShop.Bll { public class Book : IBook { public string Name { get; set; } public string Author { get; set; } public int Price { get; set; } public override string ToString() { return $"{Author} {Name}"; } } }
using System; namespace BookShop.Bll { public class Check : ICheck { public IShop Shop { get; set; } public IBook Book { get; set; } public DateTime DateTime { get; set; } public override string ToString() { return DateTime.ToString(); } } }
using System; using System.Collections.Generic; namespace BookShop.Bll { public class Shop : IShop { public string Name { get; set; } public string Address { get; set; } public void Add(IBook book) { throw new NotImplementedException(); } public IEnumerable GetAllBooks() { throw new NotImplementedException(); } public ICheck Sell(IBook book) { throw new NotImplementedException(); } public override string ToString() { return Name; } } }
При этом методы пока я оставлю не реализованными, просто потому что на этом этапе еще не все необходимые для работы классы объявлены. Но даже сейчас можно заметить, что на объявление интерфейсов в коде ушло куда меньше времени и сил, по сравнению с классами.
Но кроме того, что процесс разработки можно удобно разделить между отдельными программистами (например, по классам), также можно легко выполнить разделение даже на различные команды (например, backend и frontend). Те, кто занимаются разработкой пользовательского интерфейса (UX) нет необходимости ждать когда будет завершена логика работы приложения. Им достаточно наличия определенных на первом этапе интерфейсов, чтобы начать разработку. Таким образом мы плавно подходим к третьему преимуществу…
Разделение на слои
Важность разделения приложения на отдельные слои сложно переоценить. Это избавляет разработчиков от огромного количества дублируемого кода и позволяет налету менять различные части приложения.
Наиболее широко известные паттерн для разделения приложения на слои это MVC (Modev-View-Controller). Но сейчас мы не будем его использовать. Пока разделим наше приложение всего на два слоя:
- Бизнес-логика — основной код определяющий как работает приложение
- Пользовательский интерфейс — вывод и вывод информации от пользователя
За бизнес-логику в нашем решении отвечает библиотека классов названая BookShop.Bll, а вот пользовательский интерфейс нам предстоит пока только создать. Пока для простоты начнем будем использовать консольное приложение BookShop.App.Cmd.
Не забываем добавить связь между проектами и начинаем реализовывать работу с логикой приложения основываясь на интерфейсах, а не на их реализациях.
BookShop/BookShop.App.Cmd
Для удобства создадим перечисление со списком доступных команд для ввода на консоль
namespace BookShop.App.Cmd { public enum Command { AddBook, GetAllBooks, SellBook, Exit, Help } }
Затем для начала добавим partial
класс для основного класса Program
, и разместим там различные хелперы для более красивого и структурированного вывода сообщений на консоль.
using System; namespace BookShop.App.Cmd { partial class Program { private static string ReadNotEmptyLine(string title) { while (true) { Console.Write($"Введите {title}: "); var input = Console.ReadLine(); if (!string.IsNullOrWhiteSpace(input)) { return input; } WriteErrorMessage($"Значение {title} не должен быть пустым"); } } private static int ReadIntLine(string title) { while (true) { var input = ReadNotEmptyLine(title); if (int.TryParse(input, out int result)) { return result; } WriteErrorMessage($"Введите целое число"); } } private static void WriteErrorMessage(string message) { var color = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine($"ОШИБКА! {message}"); Console.ForegroundColor = color; } private static Command ReadCommand() { while (true) { var input = ReadNotEmptyLine("команду"); if (Enum.TryParse(input, true, out Command command)) { return command; } WriteErrorMessage("Не известная команда. Введите help для подсказки"); } } private static void WriteHelpMessage() { Console.WriteLine($"{Command.AddBook} - Добавить новую книгу;"); Console.WriteLine($"{Command.GetAllBooks} - Вывести полный список доступных книг;"); Console.WriteLine($"{Command.SellBook} - Продать книгу;"); Console.WriteLine($"{Command.Exit} - Выход из приложения;"); Console.WriteLine($"{Command.Help} - Помощь;"); Console.WriteLine(); } } }
Данная часть является не обязательно, но благодаря этому в основной части приложения нужно писать значительно меньшее количество кода и кроме того, читать код тоже становится проще.
Теперь переходим к основной логике пользовательского интерфейса. Нам необходимо определить методы, которые будут работать с бизнес-логикой. При этом обрати внимание, что мы все также взаимодействуем именно через интерфейсы, а не через конкретные классы.
private static void AddBook(IShop shop) { Console.WriteLine("Добавление новой книги"); var author = ReadNotEmptyLine("Имя автора"); var name = ReadNotEmptyLine("Название книги"); var price = ReadIntLine("Стоимость книги"); var book = CreateBook(name, author, price) ?? throw new Exception("Ошибка при добавлении книги"); shop.Add(book); Console.WriteLine("Книга успешно добавлена"); Console.WriteLine(); } private static void GetAllBooks(IShop shop) { Console.WriteLine("Список всех доступных в магазине книг:"); var books = shop.GetAllBooks(); foreach(var book in books) { Console.WriteLine($"\t{book}"); } Console.WriteLine(); } private static void SellBook(IShop shop) { Console.WriteLine("Новая продажа книги"); IBook book; while (true) { var name = ReadNotEmptyLine("Название книги"); var books = shop.GetAllBooks(); var result = books.FirstOrDefault(b => b.Name.Equals(name)); if(result != null) { book = result; break; } WriteErrorMessage("Данная книга не найдена"); } var check = CreateCheck(shop, book); Console.WriteLine($"Новая продажа в магазине {check.Shop.Name}"); Console.WriteLine($"по адресу {check.Shop.Address}"); Console.WriteLine($"{check.DateTime}"); Console.WriteLine($"Наименование товара: {check.Book}"); Console.WriteLine($"Стоимость: {check.Book.Price}₽"); Console.WriteLine(); }
Данные методы нужны для того, чтобы реализовать контролируемый ввод данных от пользователя и на их основе создавать необходимые сущности. Но полностью отказаться от использования конкретных классов просто не возможно. В какой-то момент нам необходимо установить соответствие между классами и интерфейсами. Этот процесс называется внедрение зависимости — dependency injection или просто DI. Пока мы можем это реализовать вручную создав несколько вспомогательных методов:
private static IBook CreateBook(string name, string author, int price) { var book = new Book { Author = author, Name = name, Price = price }; return book; } private static ICheck CreateCheck(IShop shop, IBook book) { var check = new Check { Shop = shop, Book = book, DateTime = DateTime.Now }; return check; } private static IShop CreateShop(string name, string address) { var shop = new Shop { Name = name, Address = address }; return shop; }
Благодаря этому, если нам потребуется поменять реализацию интерфейса на другой класс, это будет необходимо сделать в одном месте. Это весьма удобно для конфигурирования работы приложения. Чуть позже мы сделаем это еще лучше, используя DI-контейнер, но для понимания важно осознавать, что в нем нет прямой необходимости, при желании все можно делать вручную. Он нужен просто для большего контроля, лаконичности и упрощения внедрения зависимостей.
Ну и наконец необходим сам основной метод Main который будет все это использовать для взаимодействия с пользователем:
static void Main(string[] args) { try { var shop = CreateShop("Black Books", "13 Little Bevan Street, Bloomsbury, London"); Console.OutputEncoding = Encoding.UTF8; Console.WriteLine("Добрый день. Добро пожаловать в панель управления магазином"); Console.WriteLine("Пожалуйста, введите нужную команду или help для помощи"); Console.WriteLine(); while(true) { switch(ReadCommand()) { case Command.Exit: Environment.Exit(0); break; case Command.Help: WriteHelpMessage(); break; case Command.AddBook: AddBook(shop); break; case Command.GetAllBooks: GetAllBooks(shop); break; case Command.SellBook: SellBook(shop); break; default: WriteErrorMessage("Не обрабатываемая команда. Свяжитесь с разработчиком"); break; } } } catch(Exception ex) { Console.Error.WriteLine(ex.Message); Console.ReadLine(); } }
Здесь все достаточно тривиально: создаем экземпляр магазина и предоставляем пользователю возможность с ним взаимодействовать через определенный набор команд. Если что-то идет не так — сообщаем об ошибке.
И мы уже даже можем немного протестировать работу нашего приложения запустив его

Но пока что это все еще не полноценное приложение, хотя бы потому что в нем отсутствует одна из важнейших функций — хранение информации. Существует множество способов хранения данных, наиболее распространенным сейчас являются реляционные базы данных, хотя не реляционные пользуются не меньшей популярностью. Но для начала мы можем использовать самый простой подход — сохранение в оперативной памяти, чтобы в дальнейшем уже выбрать что-то более подходящее.
Реализация полиморфной связи
Одно только слово полиморфизм пугает большинство начинающих разработчиков, а иногда даже опытные специалисты не могут дать конкретного определение этому явлению, переходя сразу к примерам. Это действительно сложно трактуемое понятие, которое намного проще просто осознавать, чем выразить словами, но я все-таки попытаюсь
Полиморфизм — это механизм, который позволяет использовать один и тот же фрагмент кода с различными типами.
То есть, мы можем менять поведение программы в зависимости от контекста. Смысл термина заложен прямо в его написании — поли-морф — много форм — код может работать по разному.
Но согласись, давать полную свободу на изменение любого поведение на любое другое — достаточно опасное занятие, поэтому было решено ввести которые ограничения, менять поведение можно только на похожее, родственное по логике работы.
Благодаря этому мы можем на место базового класса подставить любой класс наследник, так как в нем будут как минимум содержаться такие-же публичные методы и свойства.
И точно также для интерфейсов, если мы объявляем интерфейсную переменную, это означает, что в дальнейшем на ее месте будет использован реальный класс, который реализует этот интерфейс. Какой именно — не важно, системе достаточно знать, что контракт будет выполнен.
Давайте добавим еще один слой в наше приложение, которое позволит сохранять информацию о книгах в памяти. Для этого в нашей основной библиотеке с бизнес-логикой определим интерфейс, который будет отвечать за доступ к данным.
using System.Collections.Generic; namespace BookShop.Bll { public interface IData<T> { IEnumerable ReadAll(); void Add(T item); void Remove(T item); } }
Интерфейс максимально упрощен и позволяет просто добавлять и удалять один элемент, и получать все элементы. В реальности операций должно быть значительно больше, но для простоты оставим так.
Теперь мы создаем новый слой приложения, который будет отвечать за сохранение данных в оперативной памяти. Для этого создадим новый проект BookShop.Data.Memory и в нем реализуем интерфейс IData<T>
для чеков и для книг. Для простоты хранения будем использовать простые списки.
BookShop/BookShop.Data.Memory
using BookShop.DI; using System.Collections.Generic; namespace BookShop.Data.Memory { public class BookMemoryData : IData<IBook> { private readonly List<IBook> _books; public BookMemoryData() { _books = new List<IBook>(); } public void Add(IBook item) { _books.Add(item); } public IEnumerable<IBook> ReadAll() { return _books; } public void Remove(IBook item) { _books.Remove(item); } } }
using BookShop.DI; using System.Collections.Generic; namespace BookShop.Data.Memory { public class CheckMemoryData : IData<ICheck> { private readonly List<ICheck> _checks; public CheckMemoryData() { _checks = new List<ICheck>(); } public void Add(ICheck item) { _checks.Add(item); } public IEnumerable<ICheck> ReadAll() { return _checks; } public void Remove(ICheck item) { _checks.Remove(item); } } }
Также нам необходимо внедрить использование данного интерфейса в магазин и наконец-то реализовать методы по работе с данными.
using System; using System.Collections.Generic; namespace BookShop.Bll { public class Shop : IShop { private readonly IData<IBook> _bookData; private readonly IData<ICheck> _checkData; public string Name { get; set; } public string Address { get; set; } public Shop(IData<IBook> bookData, IData<ICheck> checkData) { _bookData = bookData ?? throw new ArgumentNullException(nameof(bookData)); _checkData = checkData ?? throw new ArgumentNullException(nameof(checkData)); } public void Add(IBook book) { _bookData.Add(book); } public IEnumerable<IBook> GetAllBooks() { return _bookData.ReadAll(); } public ICheck Sell(IBook book) { _bookData.Remove(book); var check = new Check { Book = book, Shop = this, DateTime = DateTime.Now }; _checkData.Add(check); return check; } public override string ToString() { return Name; } } }
А также потребуется несколько изменений в нашем консольном приложении, так как именно там происходит внедрение зависимости и так как мы изменили конструктор магазина. При этом необходимо будет добавить связь между проектами интерфейса и хранения данных (что плохо, скоро мы от этого избавимся, но пока для понимания процесса это необходимо).
private static IShop CreateShop(string name, string address) { var bookData = new BookMemoryData(); var checkData = new CheckMemoryData(); var shop = new Shop(bookData, checkData); shop.Name = name; shop.Address = address; return shop; }
Благодаря этому у нас наконец-то есть возможность реально начать работать с нашим приложением, например добавлять книги или получать полный список литературы в магазине.

Отлично, прогресс на лицо, но согласись, что-то не так. У нас появляется сильная связанность между слоями, получается теперь интерфейс зависит от хранилища данных, что не очень хорошо. Для того, чтобы решить эту проблему нам необходимо выполнить инверсию управления — Inversion of Control, IoC, а также для удобства внедрить использование DI-контейнера, чем мы сейчас и займемся.
Использование DI контейнера
Любой DI-контейнер по своей сути является простым фреймворком, который просто автоматизирует и упрощает механизм внедрения зависимостей, а также выполняет контроль конфигурации системы. Существуют различные решения, но я буду использовать SimpleInjector. Он доступен в nuget и элементарно просто устанавливается в наше решение.
Но для дополнительной универсальности и независимости слоев нам потребуется вынести все имеющиеся интерфейсы в отдельную библиотеку BookShop.DI. Именно с помощью нее будет выполняться связь между слоями. При этом не забудьте удалить лишние связи, которые остались от предыдущей реализации. Теперь каждый из проектов должен зависеть только от BookShop.DI, но не от друг друга. Никаких изменений с самими интерфейсами кроме изменения пространства имен делать не нужно, просто перенести в новый проект.
Также нам понадобится еще один проект BookShop.Settings, в котором как раз-таки и будет храниться конфигурация системы и выполняться внедрение зависимостей. Благодаря этому у нас будет одно единственное место, где мы сможем легко менять конфигурацию системы, например, замерить хранение информации в оперативной памяти на работу с базой данных. И сделать это можно будет всего одной строчкой (реализация чуть позже).

BookShop/Settings
using BookShop.Bll; using BookShop.Data.Memory; using BookShop.DI; using SimpleInjector; namespace BookShop.Settings { public class Configuration { public Container Container { get; } public Configuration() { Container = new Container(); Setup(); } public virtual void Setup() { Container.Register<IBook, Book>(Lifestyle.Transient); Container.Register<ICheck, Check>(Lifestyle.Transient); Container.Register<IShop, Shop>(Lifestyle.Singleton); Container.Register<IData<IBook>, BookMemoryData> (Lifestyle.Singleton); Container.Register<IData<ICheck>, CheckMemoryData> (Lifestyle.Singleton); } } }
Именно здесь в методе Setup()
с помощью команды Register()
мы задаем непосредственное соответствие интерфейсов и используемой для них реализации. Рассказывать про принципы работы SimpleInjector здесь я не буду, это тема для отдельной и достаточно объемной статьи, но для понимания будет достаточно знать, что каждый раз, когда мы в дальнейшем будем обращаться к контейнеру через интерфейс, нам будет возвращаться заданный здесь для него класс.
При этом мы можем регулировать, как много экземпляров класса могут существовать в системе. Это задается параметром Lifestyle. Если мы установили значение Lifestyle.Transient
, то при каждом обращении будет создаваться новый экземпляр класса, то есть по сути будет работать как обычный конструктор.
Если же мы устанавливаем значение Lifestyle.Singleton
, то при каждом обращении нам будет возвращаться один и тот же экземпляр класса, то есть это будет реализация паттерна проектирования Одиночка или как его чаще называют Синглтон.
Теперь нам потребуется внести достаточно серьезные правки в метод Main() в консольном приложении, чтобы удалить прямую связь с бизнес-логикой и хранилищем данных, а оставить зависимость только от контейнера и интерфейсов.
using BookShop.DI; using BookShop.Settings; using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace BookShop.App.Cmd { partial class Program { #region DI - Внедрение зависимости // Устанавливаем зависимость от контейнера private static Configuration _configuration; private static IBook CreateBook(string name, string author, int price) { // Создаем новый экземпляр книги var book = _configuration.Container.GetInstance<IBook>(); book.Author = author; book.Name = name; book.Price = price; // Получаем существующий экземпляр магазина var shop = _configuration.Container.GetInstance<IShop>(); shop.Add(book); return book; } private static ICheck CreateCheck(IBook book) { var shop = _configuration.Container.GetInstance<IShop>(); var check = shop.Sell(book); return check; } private static IShop CreateShop(string name, string address) { var shop = _configuration.Container.GetInstance<IShop>(); shop.Name = name; shop.Address = address; return shop; } private static IEnumerable<IBook> GetAllBooks() { var shop = _configuration.Container.GetInstance<IShop>(); var books = shop.GetAllBooks(); return books; } #endregion static void Main(string[] args) { try { _configuration = new Configuration(); var shop = CreateShop("Black Books", "13 Little Bevan Street, Bloomsbury, London"); Console.OutputEncoding = Encoding.UTF8; Console.WriteLine("Добрый день. Добро пожаловать в панель управления магазином"); Console.WriteLine("Пожалуйста, введите нужную команду или help для помощи"); Console.WriteLine(); while(true) { switch(ReadCommand()) { case Command.Exit: Environment.Exit(0); break; case Command.Help: WriteHelpMessage(); break; case Command.AddBook: AddBook(); // В методах нет зависимости от shop break; case Command.GetAllBooks: ShowAllBooks(); break; case Command.SellBook: SellBook(); break; default: WriteErrorMessage("Не обрабатываемая команда. Свяжитесь с разработчиком"); break; } } } catch(Exception ex) { Console.Error.WriteLine(ex.Message); Console.ReadLine(); } } private static void AddBook() { Console.WriteLine("Добавление новой книги"); var author = ReadNotEmptyLine("Имя автора"); var name = ReadNotEmptyLine("Название книги"); var price = ReadIntLine("Стоимость книги"); var book = CreateBook(name, author, price) ?? throw new Exception("Ошибка при добавлении книги"); Console.WriteLine($"Книга [{book}] успешно добавлена"); Console.WriteLine(); } private static void ShowAllBooks() { Console.WriteLine("Список всех доступных в магазине книг:"); var books = GetAllBooks(); foreach (var book in books) { Console.WriteLine($"\t{book}"); } Console.WriteLine(); } private static void SellBook() { Console.WriteLine("Новая продажа книги"); IBook book; while (true) { var name = ReadNotEmptyLine("Название книги"); var books = GetAllBooks(); var result = books.FirstOrDefault(b => b.Name.Equals(name)); if(result != null) { book = result; break; } WriteErrorMessage("Данная книга не найдена"); } var check = CreateCheck(book); Console.WriteLine($"Новая продажа в магазине {check.Shop.Name}"); Console.WriteLine($"по адресу {check.Shop.Address}"); Console.WriteLine($"{check.DateTime}"); Console.WriteLine($"Наименование товара: {check.Book}"); Console.WriteLine($"Стоимость: {check.Book.Price}₽"); Console.WriteLine(); } } }
Теперь для того, чтобы получить экземпляр конкретного класса необходимо обращаться с запросом к контейнеру. И из-за того, что запрашиваем мы именно интерфейс реализация может прийти любая, которую мы укажем в настройках.
Например, теперь мы можем изменив всего две строчки в настройках контейнера замерить сохранение данных в оперативной памяти на использование реляционной базы данных. Или даже при необходимости совместить их.
public virtual void Setup() { Container.Register<IBook, Book>(Lifestyle.Transient); Container.Register<ICheck, Check>(Lifestyle.Transient); Container.Register<IShop, Shop>(Lifestyle.Singleton); Container.Register<IData<IBook>, BookSqlData>(Lifestyle.Singleton); Container.Register<IData<ICheck>, CheckSqlData>(Lifestyle.Singleton); }
Правда для этого нам предварительно придется создать проект BookShop.Data.Sql, который будет реализовывать работу с базой данный через Entity Framework.
BookShop/BookShop.Data.Sql
Для этого нам потребуется реализовать базовые интерфейсы, чтобы создать и читать информацию о базовых сущностях, но с небольшими изменениями. Так как это база данных мы добавим дополнительные поля, например идентификаторы, которых нет в самих интерфейсах.
using BookShop.DI; namespace BookShop.Data.Sql { public class BookEntity : IBook { public int Id { get; set; } public string Name { get; set; } public string Author { get; set; } public int Price { get; set; } public BookEntity() { } public BookEntity(IBook item) { Id = 0; Name = item.Name; Author = item.Author; Price = item.Price; } public override string ToString() { return $"{Author} {Name}"; } } }
using BookShop.DI; using System; namespace BookShop.Data.Sql { public class CheckEntity : ICheck { public int Id { get; set; } public string ShopName { get; set; } public string BookName { get; set; } public IShop Shop { get; set; } public IBook Book { get; set; } public DateTime DateTime { get; set; } public CheckEntity() { } public CheckEntity(ICheck item) { Id = 0; Shop = item.Shop; ShopName = item.Shop.Name; Book = item.Book; BookName = item.Book.Name; DateTime = item.DateTime; } public override string ToString() { return DateTime.ToString(); } } }
Так как я стараюсь максимально упростить приложение, я не стал реализовывать связи между сущностями, а просто сохраняю текстовую информацию. Это кардинально не правильный подход при работе с настоящими приложениями, но для демонстрации пойдет. Также я создал клонирующие конструкторы, которые преобразуют сущность из интерфейса в конкретный класс.
Также сразу же хочется заметить, что куда более правильно было бы также разделить интерфейс IShop
на два разных интерфейса, первый должен описывать модель, а второй — действия, но для упрощения это тоже не сделано.
Теперь настроим Entity Framework. Создадим контекст с коллекциями, а также добавим в App.Config строку подключения к базе данных
using System.Data.Entity; namespace BookShop.Data.Sql { internal class BookShopContext : DbContext { internal BookShopContext() : base("BookShop") { } public DbSet<BookEntity> Books { get; set; } public DbSet<CheckEntity> Checks { get; set; } // Хак для решения проблемы потерянного провайдера private void FixEfProviderServicesProblem() { var instance = System.Data.Entity.SqlServer.SqlProviderServices.Instance; } } }
<?xml version="1.0" encoding="utf-8"?> <configuration> <configSections> <!-- For more information on Entity Framework configuration, visit http://go.microsoft.com/fwlink/?LinkID=237468 --> <section name="entityFramework" type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false"/> </configSections> <connectionStrings> <add name="BookShop" connectionString="Server=(localdb)\mssqllocaldb;Database=BookShop;Trusted_Connection=True;" providerName="System.Data.SqlClient" /> </connectionStrings> <entityFramework> <providers> <provider invariantName="System.Data.SqlClient" type="System.Data.Entity.SqlServer.SqlProviderServices, EntityFramework.SqlServer"/> </providers> </entityFramework> </configuration>
И наконец нам остается реализовать интерфейсы для работы с данными книг и чеков (информацию о магазине игнорируем).
using BookShop.DI; using System.Collections.Generic; using System.Linq; namespace BookShop.Data.Sql { public class BookSqlData : IData<IBook> { public void Add(IBook item) { using(var db = new BookShopContext()) { var book = new BookEntity(item); db.Books.Add(book); db.SaveChanges(); } } public IEnumerable<IBook> ReadAll() { using (var db = new BookShopContext()) { return db.Books.ToList(); } } public void Remove(IBook item) { using (var db = new BookShopContext()) { var book = db.Books.SingleOrDefault( b => b.Author.Equals(item.Author) && b.Name.Equals(item.Name) && b.Price.Equals(item.Price)); db.Books.Remove(book); db.SaveChanges(); } } } }
using BookShop.DI; using System.Collections.Generic; using System.Linq; namespace BookShop.Data.Sql { public class CheckSqlData : IData<ICheck> { public void Add(ICheck item) { using(var db = new BookShopContext()) { var check = new CheckEntity(item); db.Checks.Add(check); db.SaveChanges(); } } public IEnumerable<ICheck> ReadAll() { using (var db = new BookShopContext()) { return db.Checks.ToList(); } } public void Remove(ICheck item) { using (var db = new BookShopContext()) { var check = new CheckEntity(item); db.Checks.Remove(check); db.SaveChanges(); } } } }
Теперь информация о книгах в магазине и об их продаже будет сохраняться в системе даже после завершения работы приложения.

Но зачем нам может понадобиться иметь сразу две различных реализации по работе с данными? И вот тут-то мы и переходим к завершающему преимуществу использования интерфейсов в этой статье
Mock тестирование
Покрывать свое приложение тестами — очень важная практика, которая несет большое количество плюсов. Например, ты можешь проверить правильно ли работает написанный тобой код. Или сразу посмотреть, будет ли в дальнейшем удобно использовать определенные интерфейсы. Тесты как минимум предоставляют пример использования кода, который может очень упростить жизнь другим разработчикам. И еще существует много других плюсов, поэтому давай добавим тесты для нашего приложения.
Будем использовать максимально простой тест с применением паттерна ААА — Arrange-Act-Assert, то есть сначала объявляем все необходимые сущности, потом выполняем действие, и наконец проверяем результат. И не забываем использовать общепринятую конвенцию для именования тестовых методов.
BookShop/Tests
using Microsoft.VisualStudio.TestTools.UnitTesting; using BookShop.Data.Sql; using System.Linq; namespace BookShop.Bll.Tests { [TestClass()] public class ShopTests { [TestMethod()] public void Sell_NewBook_CheckCreatedInDb() { // Arrange var bookData = new BookSqlData(); var checkData = new CheckSqlData(); var shop = new Shop(bookData, checkData); var book = new Book { Author = "TestAuthor", Name = "TestName", Price = 100 }; // Act shop.Add(book); var books = shop.GetAllBooks().ToList() ; var check = shop.Sell(book); // Assert Assert.IsNotNull(books); Assert.IsNotNull(check); } } }
Казалось бы все хорошо, вот только после каждого выполнения теста в базе данных остаются лишние артефакты, например чеки, да и слишком частое обращение к базе данных тоже не желательно из-за снижения скорости выполнения теста. Кроме того, сейчас все данные ссыпаются в одну и ту же БД, что не хорошо. Можно было бы легко поменять строку подключения и сделать тестовый экземпляр, но это достаточно накладно.
Поэтому верным решением в данном случае будет как раз использование оперативной памяти в качестве хранилища информации. Это увеличит скорость работы и избавит нас от необходимости удалять ненужные данные.
И для изменения нам всего лишь достаточно передать в конструктор магазина другую реализацию. Сам тестовый сценарий остается нетронутым. А еще лучше, мы можем разделить эти два теста на разные категории — интеграционный, чтобы проверить работу с базой данных (который выполняется редко и по ручному запросу), модульный, чтобы проверить общую логику работы (который используется для автоматического и реграционного тестирования)
[TestMethod(), TestCategory("Unit")] public void Sell_NewBook_CheckCreatedInMemory() { // Arrange var bookData = new BookMemoryData(); var checkData = new CheckMemoryData(); var shop = new Shop(bookData, checkData); var book = new Book { Author = "TestAuthor", Name = "TestName", Price = 100 }; // Act shop.Add(book); var books = shop.GetAllBooks().ToList(); var check = shop.Sell(book); // Assert Assert.IsNotNull(books); Assert.IsNotNull(check); }
Таким образом мы сможем удобно тестировать наше приложение без лишней нагрузки на базу данных, а при необходимости проверять и ее.

Даже здесь разница в скорости работы тестов явно бросается в глаза. А теперь представьте, что у тебя будет включено живое тестирование, и все тесты будут прогоняться автоматически после каждого изменения строки кода. Скорость и количество артефактов начнут зашкаливать.
Такой подход называется mock-тестированием, когда на место настоящей реализации подставляется упрощенная. Например, как в нашем примере, вместо базы данных, можно использовать список.
Это существенно увеличивает скорость разработки, так как позволяет на начальном этапе не тратить время на настоящую реализацию, но при этом уже позволяет отлавливать ошибки.
Интерфейсы C# на практике
На этом достаточно тривиальном примере я постарался на практике продемонстрировать все преимущества использования интерфейсов C# в своих приложениях. Это действительно невероятно мощный и удобный инструмент для проектирования, работы в команде и создания гибких и слабо связанных систем.
Я очень рекомендую самостоятельно скачать проект с GitHub, запустить, попробовать изменить и доработать его. Кстати, по истории коммитов можно отследить путь развития решения из этой статьи. Тоже неплохой инструмент для изучения прогресса разработки.
И самым важным шагом для понимания работы интерфейсов в C# будет создание своего собственного тестового проекта в той предметной области, которая интересно именно вам. Не нужно изначально проектировать что-то грандиозное, начинайте с малого и постепенно добавляйте все новые и новые функциональные возможности.
Советую прочитать предыдущую статью — Интерфейсы C# — Самый подробный разбор
А также подписывайтесь на группу ВКонтакте, Telegram, Инстаграм и YouTube-канал. Там еще больше полезного и интересного для программистов.