Работа с денежными значениями в C# кажется простой, пока неправильная реализация не приводит к ошибкам округления, несоответствиям и финансовым потерям, которые трудно обнаружить. Изучите лучшие практики программирования финансовых систем в ASP.NET Core.
Представьте, что вам нужно внедрить новый финансовый модуль, где деньги являются основной сущностью системы: платежи, возмещения, периодические платежи, скидки, проценты и налоги — все это ежедневно связано с ними.
На первый взгляд, реализация кажется простой. В конце концов, нужно просто создать несколько свойств типа decimal, выполнить математические операции и сохранить значения в базу данных. Верно? Не всегда.
Небольшие решения при реализации могут привести к серьезным проблемам. В системах, обрабатывающих тысячи или миллионы транзакций, разница всего в несколько центов может превратиться в значительные финансовые потери. По этой причине рассматривать деньги просто как число — часто ошибка. Денежные значения должны иметь свои собственные правила поведения и ограничения, отраженные в коде.
В этой статье мы рассмотрим шаблоны и лучшие практики обработки денег в приложениях на C#, начиная от выбора правильного типа данных и заканчивая созданием объекта-значения (Value Object), который делает домен более безопасным, выразительным и менее подверженным ошибкам.
💸 Распространенные ошибки при работе с денежными значениями
Прежде чем обсуждать шаблоны, важно понять наиболее частые ошибки, совершаемые при реализации финансовых функций. Казалось бы, безобидные решения во время разработки являются одними из главных причин финансовых несоответствий. Давайте рассмотрим некоторые из них.
Использование double или float для денежных значений
Частая ошибка — использование типов с плавающей запятой (double или float) для представления денежных значений.
Запуск в Fiddle: https://dotnetfiddle.net/TpFz0k
Обратите внимание, что результат оказался чрезвычайно длинным значением. Это происходит потому, что double и float хранят значения, используя двоичное представление с плавающей запятой, которое не может точно представить многие десятичные числа.
Для финансовых расчетов всегда отдавайте предпочтение типу decimal, который был разработан для выполнения денежных операций с точностью, как показано в примере ниже:
Запуск в Fiddle: https://dotnetfiddle.net/bmQSR6
Представление денег как примитивного типа
Еще одна ошибка — рассматривать деньги просто как свойство типа decimal:
Проблема этого подхода в том, что он поднимает несколько важных вопросов: какую валюту представляет это значение? Можно ли использовать отрицательные значения? Как следует обрабатывать округление? Можно ли складывать значения в разных валютах?
Примитивный тип не может обеспечить соблюдение этих бизнес-правил, что делает систему более уязвимой для ошибок. Более надежным подходом было бы использование объекта-значения, как мы увидим позже.
Правила округления разбросаны по всему приложению
Финансовые системы часто имеют специфические требования к округлению. В этом контексте нередко можно встретить приложения, которые выполняют операции округления в различных частях кода, как показано в примере ниже.
Когда правила округления меняются, каждый экземпляр должен быть пересмотрен, что увеличивает риск того, что что-то будет упущено, что потенциально приведет к финансовым потерям.
Лучший подход — централизовать денежные операции в выделенном классе, отвечающем за последовательное и явное применение бизнес-правил, все в одном месте.
Допущение недействительных денежных состояний
При реализации финансовых систем часто встречается домен, который накладывает ограничения, не всегда гарантируемые кодом. Например, цена товара не может быть отрицательной, общая сумма счета не может быть меньше нуля, а скидка не может превышать стоимость заказа.
Когда деньги представлены как примитивный тип, легко создать недействительные состояния. Например:
Компилятор принимает это, но бизнес-правило — нет. Поэтому выделенный класс для представления денег может проверять эти правила при создании экземпляров.
Смешивание бизнес-логики с денежными расчетами
Финансовые расчеты часто оказываются разбросанными по сервисам, контроллерам и репозиториям. Однако по мере развития приложения эта логика становится дублируемой и трудной для понимания:
Лучшим подходом было бы инкапсулировать поведение денежных значений внутри выделенных доменных объектов. Это помогло бы поддерживать согласованность расчетов и облегчить развитие системы.
💹 Правильная обработка денежных значений
В предыдущем разделе мы рассмотрели некоторые основные ошибки, которые совершают разработчики при реализации систем, обрабатывающих денежные значения. Теперь давайте изучим лучшие практики и рекомендуемые подходы для создания финансовых систем, которые являются более надежными и менее подверженными ошибкам.
💰 Шаблон под названием Money
Мартин Фаулер в своей книге «Архитектура корпоративных программных приложений» представляет Money как шаблон для моделирования денежных значений. Идея проста: деньги — это не просто число. Денежное значение состоит как минимум из двух неотделимых элементов: значения (суммы) и валюты.
Такое моделирование предотвращает недействительные ситуации, такие как, например, сложение долларов с евро или сравнение значений в разных валютах без адекватной конвертации.
Более того, экземпляр Money должен быть неизменяемым (immutable). Вместо изменения своего внутреннего состояния, операции, такие как сложение или вычитание, возвращают новый экземпляр, содержащий результат операции. Это делает код более предсказуемым и уменьшает побочные эффекты.
Реализация Money в приложении ASP.NET Core
Представьте, что мы разрабатываем финансовую систему для управления цифровым кошельком. Система должна записывать депозиты и снятия средств и учитывать различные валюты. Помимо наличия обновленного баланса кошелька, нам нужно вести историю всех транзакций.
Следуя шаблону Мартина, имеет смысл моделировать деньги как доменную концепцию, в данном случае как объект-значение, а не просто как примитивный тип.
Чтобы сохранить фокус статьи, некоторые детали реализации будут опущены, но вы можете получить доступ к полному коду в этом репозитории GitHub: Исходный код Wallet API.
Внутри слоя «Domain» мы могли бы создать следующие сущности: Money, Currency, WalletAccount и Transaction:
Money:
Обратите внимание, что здесь Money рассматривается как доменная концепция, а не как примитивный тип. Это именно то, что предлагает Фаулер в своем шаблоне. У денег есть свои бизнес-правила, такие как валюта, округление и допустимые операции. Поэтому их не следует представлять просто десятичным числом, разбросанным по всему приложению.
Инкапсулируя эти правила внутри класса Money, мы устраняем «Primitive Obsession» (одержимость примитивами) — один из «запахов» кода, описанных Фаулером. Вместо множества методов, принимающих отдельные аргументы decimal и string, существует единый объект, который точно представляет концепцию денег.
Неизменяемость — еще один ключевой момент. И Amount, и Currency имеют только геттеры и определяются исключительно в конструкторе. Методы Add и Subtract никогда не изменяют текущий экземпляр. Вместо этого они всегда возвращают новый объект Money.
Более того, согласно Эрику Эвансу, объекты-значения (Value Objects) должны быть неизменяемыми, когда это возможно. Фаулер также рекомендует этот подход для Money, поскольку денежные значения представляют собой математическую концепцию. Если бы две операции использовали один и тот же изменяемый экземпляр, могли бы возникнуть различные трудноуловимые ошибки, такие как проблемы с параллельными операциями, неожиданные побочные эффекты или случайное изменение общих балансов. Всегда возвращая новый экземпляр, класс делает подобные ситуации невозможными.
Обратите внимание также на свойство Currency, которое иллюстрирует один из важнейших моментов паттерна Money, продвигаемого Фаулером. Деньги — это никогда не просто число: значения 100 USD, 100 EUR и 100 BRL представляют собой совершенно разные вещи. Именно поэтому Фаулер утверждает, что деньги состоят из двух неотделимых частей информации: суммы (Amount) и валюты (Currency).
Еще один важный момент заключается в том, что мы используем MidpointRounding.ToEven (известное как банковское округление), которое часто рекомендуется в финансовых приложениях, поскольку оно уменьшает статистическую погрешность, возникающую при постоянном округлении в большую сторону.
Currency:
Класс Currency играет значительную роль в предметной области. Он централизует концепцию допустимых валют приложения, предотвращая использование произвольных значений при создании объекта Money.
Ключевой момент заключается в том, что это позволяет избежать использования «магических строк», разбросанных по всей системе. Без этого класса можно было бы часто увидеть код вроде new Money(50, "USD");. Проблема такого подхода в том, что опечатки остаются незамеченными компилятором и могут привести к серьезным ошибкам в приложении, например, new Money(100, "Us");.
Благодаря определению выделенного класса Currency код становится намного безопаснее и выразительнее. Другими словами, это не только уменьшает количество опечаток, но и более четко передает намерения.
WalletAccount:
Класс WalletAccount представляет собой основную сущность предметной области, отвечающую за цифровой кошелек. В отличие от Money, который является объектом-значением, WalletAccount обладает собственной идентичностью, представленной идентификатором Id. В предметно-ориентированном проектировании (Domain-Driven Design) это именно та определяющая характеристика сущности: она распознается не просто по своим атрибутам, а по уникальной идентичности, которая остается неизменной на протяжении всего жизненного цикла.
Обратите внимание, что мы используем ранее созданный объект Money. Еще один ключевой момент заключается в том, что сущность никогда не позволяет изменять баланс напрямую. Свойство Balance имеет закрытый сеттер, что не позволяет любой части приложения произвольно присваивать новое значение. Это сохраняет инкапсуляцию и направляет все изменения через бизнес-методы, определенные самой сущностью.
Методы Deposit и Withdraw иллюстрируют это поведение. Вместо того чтобы открывать доступ к балансу для выполнения ручных вычислений другими классами, мы определяем операции, которые имеют смысл в рамках предметной области. Этот подход следует ключевому объектно-ориентированному принципу, известному как «Tell, Don’t Ask» (Говори, а не спрашивай). Вместо того чтобы получать баланс, выполнять вычисления извне, а затем присваивать новое значение, мы просто даем команду самому кошельку выполнить желаемую операцию.
Еще одна интересная деталь заключается в том, что сущность полностью делегирует правила, связанные с деньгами, объекту Money. Когда выполняется депозит, кошельку не нужно знать, как складывать денежные значения; он просто просит объект Money выполнить эту операцию. То же самое относится и к снятию средств. Это приводит к надежному распределению обязанностей: кошелек обрабатывает правила, специфичные для счета, а объект Money обрабатывает правила, связанные с самими деньгами.
WalletAccount управляет поведением кошелька, в то время как Money инкапсулирует все, что касается денежных значений. В результате модель остается организованной, легкой для понимания и более простой в развитии.
Transaction:
Класс Transaction отвечает за запись события, произошедшего в предметной области: депозита или снятия средств. Сущность создается с помощью статического метода Create. Вместо того чтобы открывать публичный конструктор, мы централизуем всю логику создания в одном месте.
Интересная деталь заключается в том, что метод получает объект Money, но сущность не сохраняет его напрямую. Вместо этого она копирует только Amount и Currency в свои собственные свойства. Это имеет смысл, если рассматривать финансовую историю. Транзакция представляет собой неизменяемую запись о событии, которое произошло в определенный момент времени. Ей не нужно хранить ссылку на объект Money, использованный во время операции, ей нужно лишь сохранить информацию, представляющую этот исторический факт.
Также важно отметить, что пример, показанный в этой статье, предназначен для образовательных целей и служит отправной точкой. В производственных сценариях транзакции потребовали бы более сложной реализации, возможно, с использованием асинхронных событий через паттерн Event Sourcing.
⏳Экономия времени с NodaMoney
NodaMoney — это библиотека .NET, вдохновленная паттерном Money, описанным Мартином Фаулером. Она предоставляет готовую к использованию реализацию для представления денежных значений, инкапсулируя такие концепции, как сумма, валюта, округление, математические операции, сравнение валют и форматирование.
Вместо создания собственного класса Money мы можем использовать класс Money, предоставляемый NodaMoney.
Обратите внимание, что если мы запустим приведенный ниже код в Fiddle, результатом будет значение, уже правильно отформатированное в долларах, просто благодаря использованию пакета NuGet NodaMoney:
Запуск в Fiddle: https://dotnetfiddle.net/XRYYMw
Однако, если мы попытаемся сложить два значения в разных валютах, мы получим ошибку, подтверждающую, что несогласованное сложение невозможно:
Запуск в Fiddle: https://dotnetfiddle.net/82TgT4
NodaMoney поддерживает автоматическое округление:
Запуск в Fiddle: https://dotnetfiddle.net/B1xoOs
NodaMoney содержит интересную функцию для распределения сумм, используемую для разделения денег без потери центов.
Рассмотрим пример, где нам нужно разделить 100 долларов между тремя людьми.
Это важно, поскольку позволяет избежать проблем с округлением и гарантирует, что сумма частей в точности равна исходной сумме.
Запуск в Fiddle: https://dotnetfiddle.net/LN8eZV
🌱 Заключительные мысли
В примерах, представленных в разделе об общих ошибках, деньги рассматривались просто как десятичное значение. Любая часть кода могла изменить эти значения, а правила были разбросаны по всей кодовой базе.
После внедрения паттерна Money Фаулера мы пришли к модели, которая четко отражает намерения предметной области. Деньги стали объектом-значением, правила были централизованы, недействительные состояния устранены, а код стал лучше передавать свои намерения через методы и поведение.
Мы также изучили использование NodaMoney для экономии времени разработки, так как она инкапсулирует ключевые функции для безопасной работы с деньгами в ASP.NET Core.
Однако важнее, чем избежание ошибок округления, — это создание модели, которая делает возникновение ошибок трудновозможным в принципе. Когда предметная область отвечает за финансовые правила, приложение становится более предсказуемым и его легче развивать.
Надеюсь, эта статья помогла вам понять, как внедрять устойчивые системы при работе с денежными значениями.










