0Pricing
C# Academy · Урок

Записи в предметно-ориентированном проектировании

Моделируйте объекты-значения, DTO и события предметной области как записи, чтобы обеспечить неизменяемость и повысить ясность кода.

«Записи в предметно-ориентированном проектировании» — бесплатный урок C# Academy на CoddyKit. Это урок 4 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения C# Academy, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс C# Academy содержит 4 уроков всего.

DDD и объекты-значения

В предметно-ориентированном проектировании (DDD) объект-значение определяется своими свойствами, а не идентичностью. Два значения Money(100, "USD") взаимозаменяемы. Записи — идеальное представление объектов-значений в C#.

Объекты-значения в виде записей

Моделируйте объекты-значения предметной области как записи. Их структурное равенство, неизменяемость и лаконичный синтаксис идеально соответствуют принципам DDD.

// Value objects — defined by their values, not identity
public record Money(decimal Amount, string Currency);
public record Email(string Value);
public record PhoneNumber(string CountryCode, string Number);
public record Address(string Street, string City, string Country, string PostalCode);

// Two Money objects with same values are equal:
var price = new Money(99.99m, "USD");
var same  = new Money(99.99m, "USD");
Console.WriteLine(price == same); // True

Проверка в конструкторах объектов-значений

Add компактный конструктор для проверки инвариантов. Объекты-значения должны создаваться только в корректном состоянии — некорректное состояние должно приводить к исключению во время создания.

public record Email(string Value)
{
    // Compact constructor for validation
    public Email : this(Value)
    {
        if (string.IsNullOrWhiteSpace(Value) || !Value.Contains('@'))
            throw new ArgumentException("Invalid email address", nameof(Value));
        Value = Value.Trim().ToLowerInvariant();
    }
}

// Usage:
var email = new Email("  Alice@Example.COM  "); // normalized to alice@example.com
new Email("not-an-email"); // throws ArgumentException

Сущности и объекты-значения

Сущности обладают идентичностью (их ID отслеживается во времени); объекты-значения идентичности не имеют и взаимозаменяемы, если их значения совпадают. В C# их следует моделировать по-разному.

// ENTITY: has identity, mutable state
public class Customer
{
    public Guid Id { get; } = Guid.NewGuid();
    public string Name { get; set; } = "";
    public Email Email { get; set; } = default!; // value object
}

// VALUE OBJECT: no identity, immutable
public record Email(string Value);
public record Money(decimal Amount, string Currency);

// Two customers with same name are DIFFERENT entities
// Two Money(100, "USD") are the SAME value

Содержательные объекты-значения с методами предметной области

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

public record Money(decimal Amount, string Currency)
{
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new DomainException("Cannot add different currencies");
        return this with { Amount = Amount + other.Amount };
    }

    public Money Subtract(Money other) =>
        Amount >= other.Amount
            ? this with { Amount = Amount - other.Amount }
            : throw new DomainException("Insufficient funds");

    public Money ApplyDiscount(decimal percent) =>
        this with { Amount = Amount * (1 - percent / 100) };

    public static Money Zero(string currency) => new(0, currency);
}

События предметной области в виде записей

События предметной области описывают произошедшие в предметной области события. Записи подходят идеально: они неизменяемы, информативны и используют структурное равенство, что упрощает тестирование.

// Domain events as records
public abstract record DomainEvent(DateTime OccurredAt);

public record OrderPlaced(
    Guid OrderId,
    Guid CustomerId,
    Money Total,
    DateTime OccurredAt) : DomainEvent(OccurredAt);

public record OrderShipped(
    Guid OrderId,
    string TrackingNumber,
    DateTime OccurredAt) : DomainEvent(OccurredAt);

public record PaymentReceived(
    Guid OrderId,
    Money Amount,
    DateTime OccurredAt) : DomainEvent(OccurredAt);

DTO и контракты API в виде записей

DTO запросов и ответов по своей природе являются объектами-значениями: они переносят данные, но не имеют идентичности. Записи делают их лаконичными и неизменяемыми.

// Request DTOs
public record CreateOrderCommand(
    Guid CustomerId,
    IReadOnlyList<OrderLineDto> Lines);

public record OrderLineDto(Guid ProductId, int Quantity);

// Response DTOs
public record OrderCreatedResponse(
    Guid OrderId,
    string Status,
    decimal Total,
    DateTime CreatedAt);

// These can be compared in tests by value:
var expected = new OrderCreatedResponse(id, "Pending", 99m, now);
Assert.Equal(expected, actual);

Хранение объектов-значений в EF Core

Используйте принадлежащие сущности для сохранения объектов-значений в EF Core. Свойства каждого объекта-значения хранятся в таблице владельца (или в отдельной таблице) без отдельного PK.

public class Order
{
    public int Id { get; set; }
    public Money Total { get; set; } = new(0, "USD");
    public Address ShipTo { get; set; } = default!;
}

// EF Core config:
modelBuilder.Entity<Order>(e =>
{
    e.OwnsOne(o => o.Total, money =>
    {
        money.Property(m => m.Amount).HasColumnType("decimal(18,2)");
        money.Property(m => m.Currency).HasMaxLength(3);
    });
    e.OwnsOne(o => o.ShipTo);
});

Корень агрегата с объектами-значениями

Объединим всё вместе: корень агрегата Order, использующий записи для объектов-значений и событий предметной области, причём все бизнес-правила обеспечиваются методами.

public class Order
{
    private readonly List<DomainEvent> _events = new();
    public IReadOnlyList<DomainEvent> DomainEvents => _events;

    public Guid Id { get; } = Guid.NewGuid();
    public Address ShipTo { get; private set; } = default!; // value obj
    public Money Total { get; private set; } = Money.Zero("USD");

    public void PlaceOrder(Address shipTo, IEnumerable<OrderLine> lines)
    {
        ShipTo = shipTo;
        Total  = lines.Aggregate(Money.Zero("USD"),
            (acc, l) => acc.Add(l.Price));
        _events.Add(new OrderPlaced(Id, CustomerId, Total, DateTime.UtcNow));
    }
}

Практический пример: типобезопасные идентификаторы

Используйте записи-обёртки для примитивных идентификаторов, чтобы предотвратить случайное смешивание идентификаторов разных сущностей — распространённый приём DDD, называемый «идентификаторы со строгой типизацией».

// Strongly typed IDs — can't confuse CustomerId with OrderId
public record CustomerId(Guid Value)
{
    public static CustomerId New() => new(Guid.NewGuid());
    public static implicit operator Guid(CustomerId id) => id.Value;
}

public record OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.NewGuid());
}

// Type safety:
void ShipOrder(OrderId orderId, CustomerId customerId) { }

ShipOrder(OrderId.New(), CustomerId.New()); // OK
ShipOrder(CustomerId.New(), OrderId.New()); // COMPILE ERROR!

Быстрая проверка

Что определяет объект-значение DDD и почему записи естественным образом для него подходят?

Повторение: записи в предметно-ориентированном проектировании

Основные выводы:

  • Объекты-значения: определяются значениями, не имеют идентичности — используйте записи
  • Сущности: имеют идентичность (ID) и изменяемое состояние — используйте классы
  • Проверяйте инварианты в компактных конструкторах — некорректное состояние невозможно представить
  • События предметной области в виде записей: неизменяемы, сравнимы, самодокументируемы
  • Принадлежащие сущности EF Core для сохранения объектов-значений
  • Идентификаторы со строгой типизацией на основе записей предотвращают смешивание идентификаторов сущностей

Часто задаваемые вопросы

Урок «Записи в предметно-ориентированном проектировании» бесплатный?

Да — полный текст урока «Записи в предметно-ориентированном проектировании» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс C# Academy, подпишись на CoddyKit PRO. Курс C# Academy содержит 4 уроков всего.

Чему я научусь в уроке «Записи в предметно-ориентированном проектировании»?

Моделируйте объекты-значения, DTO и события предметной области как записи, чтобы обеспечить неизменяемость и повысить ясность кода. Ты практикуешь C# Academy с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать C# Academy?

Предыдущий опыт не требуется. C# Academy на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 4 из 4.

Сколько времени занимает урок «Записи в предметно-ориентированном проектировании»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке C# Academy?

Да. Каждый урок C# Academy включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. Типы записей: основы и синтаксис
  2. Неизменяемость с init и with
  3. Равенство значений и деконструкция
  4. Записи в предметно-ориентированном проектировании
← Назад к C# Academy