Ef Core

Складні типи — Owned Types та Complex Types (Частина 1)

Глибокий розбір Owned Types в EF Core — OwnsOne, OwnsMany, вкладені owned types, table splitting, entity splitting. Теорія Value Objects з DDD, практична реалізація агрегатів.

Складні типи: Owned Types та Complex Types

Проблема, яку ми вирішуємо

Розглянемо типового замовника інтернет-магазину. У базі є таблиця Customers зі стовпцями: Id, Name, Email, ShippingStreet, ShippingCity, ShippingCountry, ShippingPostalCode, BillingStreet, BillingCity, BillingCountry, BillingPostalCode. Тринадцять стовпців для сутності, що концептуально є простою: «замовник з двома адресами».

У C# ж ми природно описали б це інакше:

var customer = new Customer
{
    Name = "Іван Петренко",
    ShippingAddress = new Address("Хрещатик 1", "Київ", "Україна", "01001"),
    BillingAddress  = new Address("Хрещатик 1", "Київ", "Україна", "01001")
};

Але як цю ієрархію класів помістити у реляційну базу? Якщо зробити Address окремою таблицею — потрібен FK, JOIN, і Address раптом набуває власної Id, хоча концептуально адреса не є окремою сутністю — вона є частиною замовника. Якщо «розплющити» всі поля адреси у таблицю Customers — ми повертаємось до початкового стану, але втрачаємо структурованість у C#.

Саме для вирішення цього impedance mismatch між об'єктним і реляційним світами у EF Core існують Owned Types і Complex Types.


Value Objects у Domain-Driven Design: теоретична база

Щоб правильно зрозуміти Owned Types, потрібно познайомитися з концепцією з Domain-Driven Design (DDD) — Value Object (об'єкт-значення).

Eric Evans у книзі «Domain-Driven Design» виділяє два фундаментальних типи доменних об'єктів.

Entity (Сутність)

Сутність визначається своєю ідентичністю, а не атрибутами. Два замовники з однаковим іменем «Іван Петренко» — різні сутності, бо мають різні ID. Навіть якщо помінялося ім'я, email, адреса — сутність залишається тою самою (той самий Id).

Характеристики Entity:

  • Має унікальний ідентифікатор (Id)
  • Може існувати незалежно
  • Рівність визначається через Id, не через значення полів
  • Має власний lifecycle (створення, зміна, видалення)

Value Object (Об'єкт-значення)

Value Object визначається своїми атрибутами. Дві адреси «Хрещатик 1, Київ» і «Хрещатик 1, Київ» — рівні, незалежно від того, чи це один і той самий об'єкт у пам'яті чи два різні. Адреса не має сенсу поза контекстом замовника або замовлення — вона є частиною іншого об'єкту.

Характеристики Value Object:

  • Немає ідентифікатора (Id)
  • Рівність визначається через рівність усіх атрибутів
  • Immutable (незмінний): якщо треба «змінити» адресу — замінюємо весь об'єкт новим
  • Не може існувати самостійно — завжди є частиною Entity або іншого Value Object
  • Семантично описує характеристику або атрибут сутності

Класичні приклади Value Objects: Address, Money (сума + валюта), DateRange, Coordinate, PhoneNumber, EmailAddress, Color.

// Entity: ідентичність через Id
public class Customer
{
    public int Id { get; private set; }
    public string Name { get; set; } = string.Empty;
    public Address ShippingAddress { get; set; } = null!;
}

// Value Object: ідентичність через значення, immutable через init
public class Address
{
    public string Street     { get; init; } = string.Empty;
    public string City       { get; init; } = string.Empty;
    public string Country    { get; init; } = string.Empty;
    public string PostalCode { get; init; } = string.Empty;

    // Рівність через значення (не через посилання)
    public override bool Equals(object? obj) =>
        obj is Address other &&
        Street == other.Street && City == other.City &&
        Country == other.Country && PostalCode == other.PostalCode;

    public override int GetHashCode() =>
        HashCode.Combine(Street, City, Country, PostalCode);

    // Незмінність: замість зміни — новий об'єкт
    public Address WithCity(string newCity) =>
        new() { Street = Street, City = newCity, Country = Country, PostalCode = PostalCode };
}

Зверніть на ключові рішення у Address:

  • init замість set — властивості можна встановити лише під час ініціалізації
  • Equals порівнює за значенням, не за посиланням
  • WithCity() — замість мутації повертає новий об'єкт

Саме такі Value Objects EF Core зберігає через механізм Owned Types.


Owned Types: OwnsOne

OwnsOne — конфігурація, що говорить EF Core: «цей об'єкт є частиною свого власника, він не є окремою сутністю». EF Core зберігає поля Owned Type у тій самій таблиці, що й власник (за замовчуванням), або в окремій таблиці за явного вказання.

Базовий приклад: Customer з Address

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public Address ShippingAddress { get; set; } = null!;
    public Address? BillingAddress { get; set; }  // nullable — може збігатися з Shipping
}

public class Address
{
    public string Street     { get; set; } = string.Empty;
    public string City       { get; set; } = string.Empty;
    public string Country    { get; set; } = string.Empty;
    public string PostalCode { get; set; } = string.Empty;
}
public class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.HasKey(c => c.Id);
        builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
        builder.Property(c => c.Email).IsRequired().HasMaxLength(320);

        // ShippingAddress — обов'язкова (not nullable в C#)
        builder.OwnsOne(c => c.ShippingAddress, addr =>
        {
            addr.Property(a => a.Street)
                .HasColumnName("ShippingStreet")
                .IsRequired().HasMaxLength(300);
            addr.Property(a => a.City)
                .HasColumnName("ShippingCity")
                .IsRequired().HasMaxLength(100);
            addr.Property(a => a.Country)
                .HasColumnName("ShippingCountry")
                .IsRequired().HasMaxLength(100);
            addr.Property(a => a.PostalCode)
                .HasColumnName("ShippingPostalCode")
                .HasMaxLength(20).IsRequired().IsUnicode(false);
        });

        // BillingAddress — необов'язкова (nullable в C#)
        builder.OwnsOne(c => c.BillingAddress, addr =>
        {
            addr.Property(a => a.Street).HasColumnName("BillingStreet").HasMaxLength(300);
            addr.Property(a => a.City).HasColumnName("BillingCity").HasMaxLength(100);
            addr.Property(a => a.Country).HasColumnName("BillingCountry").HasMaxLength(100);
            addr.Property(a => a.PostalCode).HasColumnName("BillingPostalCode")
                .HasMaxLength(20).IsUnicode(false);
        });
    }
}

Генерований DDL (SQL Server):

CREATE TABLE [Customers] (
    [Id]                  INT           NOT NULL IDENTITY,
    [Name]                NVARCHAR(200) NOT NULL,
    [Email]               NVARCHAR(320) NOT NULL,
    -- ShippingAddress (обов'язкова — NOT NULL)
    [ShippingStreet]      NVARCHAR(300) NOT NULL,
    [ShippingCity]        NVARCHAR(100) NOT NULL,
    [ShippingCountry]     NVARCHAR(100) NOT NULL,
    [ShippingPostalCode]  VARCHAR(20)   NOT NULL,
    -- BillingAddress (необов'язкова — NULL)
    [BillingStreet]       NVARCHAR(300) NULL,
    [BillingCity]         NVARCHAR(100) NULL,
    [BillingCountry]      NVARCHAR(100) NULL,
    [BillingPostalCode]   VARCHAR(20)   NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);

Жодної окремої таблиці Addresses! Всі поля обох адрес — у таблиці Customers. Це і є суть OwnsOne без ToTableinline embedding.

Робота з Owned Types у коді

// Створення
var customer = new Customer
{
    Name  = "Оксана Коваленко",
    Email = "oksana@example.com",
    ShippingAddress = new Address
    {
        Street     = "вул. Хрещатик, 1",
        City       = "Київ",
        Country    = "Україна",
        PostalCode = "01001"
    }
    // BillingAddress = null — прийнятно для nullable Owned Type
};

context.Customers.Add(customer);
await context.SaveChangesAsync();
// INSERT INTO Customers (Name, Email, ShippingStreet, ShippingCity, ...)
// Читання: Owned Types завжди завантажуються разом з власником
// (немає потреби у Include — вони частина того ж рядка)
var customer = await context.Customers.FindAsync(1);
Console.WriteLine(customer!.ShippingAddress.City); // Київ

// Зміна адреси: замінюємо весь Value Object (DDD-стиль)
customer.ShippingAddress = new Address
{
    Street = "пр. Перемоги, 10", City = "Київ",
    Country = "Україна", PostalCode = "01011"
};
await context.SaveChangesAsync();
// UPDATE Customers SET ShippingStreet=..., ShippingCity=..., ... WHERE Id=1
// Запит з фільтром по полях Owned Type
var kyivCustomers = await context.Customers
    .Where(c => c.ShippingAddress.City == "Київ")
    .OrderBy(c => c.Name)
    .ToListAsync();
// SQL: SELECT ... FROM Customers WHERE ShippingCity = 'Київ'
Owned Types завжди завантажуються разом з власником при inline-зберіганні — вони є частиною того самого SQL-рядка. Немає N+1 проблеми, немає Include. Якщо ж Owned Type розміщений в окремій таблиці (через ToTable) — додається JOIN при кожному запиті.

OwnsOne з окремою таблицею (Split Owned)

Якщо Owned Type містить багато полів (10+) або використовується рідко — є сенс винести його в окрему таблицю через ToTable всередині OwnsOne:

public class UserProfile
{
    public int Id { get; set; }
    public string Username { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public ExtendedProfile? Extended { get; set; }
}

public class ExtendedProfile
{
    public string? Bio          { get; set; }
    public string? AvatarUrl    { get; set; }
    public string? WebsiteUrl   { get; set; }
    public DateOnly? DateOfBirth { get; set; }
    public string? PhoneNumber  { get; set; }
    public string? TwitterHandle { get; set; }
    public string? GitHubHandle  { get; set; }
    // ... ще поля
}
builder.OwnsOne(u => u.Extended, ext =>
{
    ext.ToTable("UserExtendedProfiles"); // власна таблиця!

    ext.Property(e => e.Bio).HasMaxLength(2000);
    ext.Property(e => e.AvatarUrl).HasMaxLength(500);
    ext.Property(e => e.PhoneNumber).HasMaxLength(30);
    ext.Property(e => e.TwitterHandle).HasMaxLength(60);
    ext.Property(e => e.GitHubHandle).HasMaxLength(60);
});

Генерована схема:

CREATE TABLE [UserExtendedProfiles] (
    [UserProfileId] INT            NOT NULL,  -- FK = PK (shared key)
    [Bio]           NVARCHAR(2000) NULL,
    [AvatarUrl]     NVARCHAR(500)  NULL,
    -- ...
    CONSTRAINT [PK_UserExtendedProfiles] PRIMARY KEY ([UserProfileId]),
    CONSTRAINT [FK_UserExtendedProfiles_UserProfiles_UserProfileId]
        FOREIGN KEY ([UserProfileId]) REFERENCES [UserProfiles] ([Id]) ON DELETE CASCADE
);

UserExtendedProfiles.UserProfileIdодночасно PK і FK. Це класичний паттерн 1:1 з поділеним PK (shared primary key).

З виокремленою таблицею потрібен Include для завантаження:

// Без Include: Extended буде null
var user = await context.UserProfiles.FindAsync(userId);

// З Include: LEFT JOIN на UserExtendedProfiles
var userWithExtended = await context.UserProfiles
    .Include(u => u.Extended)
    .FirstOrDefaultAsync(u => u.Id == userId);

OwnsMany: колекція Value Objects

OwnsMany — аналог OwnsOne для колекцій. Він завжди вимагає окремої таблиці, оскільки один рядок власника пов'язаний з багатьма рядками Owned Type.

Приклад: Order з OrderLineItems

public class Order
{
    public int Id { get; set; }
    public string OrderNumber { get; set; } = string.Empty;
    public DateTime PlacedAt { get; set; }
    public OrderStatus Status { get; set; }
    public ICollection<OrderLineItem> LineItems { get; set; } = new List<OrderLineItem>();
}

// Value Object: рядок замовлення
public class OrderLineItem
{
    public string ProductSku  { get; set; } = string.Empty;
    public string ProductName { get; set; } = string.Empty;
    public int    Quantity    { get; set; }
    public decimal UnitPrice  { get; set; }
    public decimal Discount   { get; set; }

    // Обчислюване — не зберігається
    public decimal SubTotal => UnitPrice * Quantity * (1 - Discount / 100);
}
builder.OwnsMany(o => o.LineItems, li =>
{
    li.ToTable("OrderLineItems");

    li.WithOwner().HasForeignKey("OrderId");
    li.Property<int>("Id");
    li.HasKey("Id");

    li.Property(l => l.ProductSku)
      .IsRequired().HasMaxLength(50).IsUnicode(false);
    li.Property(l => l.ProductName)
      .IsRequired().HasMaxLength(200);
    li.Property(l => l.Quantity).IsRequired();
    li.Property(l => l.UnitPrice).HasPrecision(12, 2).IsRequired();
    li.Property(l => l.Discount).HasPrecision(5, 2).HasDefaultValue(0m);
    li.Ignore(l => l.SubTotal);
});

Генерований DDL:

CREATE TABLE [OrderLineItems] (
    [Id]          INT             NOT NULL IDENTITY,
    [OrderId]     INT             NOT NULL,
    [ProductSku]  VARCHAR(50)     NOT NULL,
    [ProductName] NVARCHAR(200)   NOT NULL,
    [Quantity]    INT             NOT NULL,
    [UnitPrice]   DECIMAL(12, 2)  NOT NULL,
    [Discount]    DECIMAL(5, 2)   NOT NULL DEFAULT 0,
    CONSTRAINT [PK_OrderLineItems] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_OrderLineItems_Orders_OrderId]
        FOREIGN KEY ([OrderId]) REFERENCES [Orders] ([Id]) ON DELETE CASCADE
);
// Читання: OwnsMany потребує Include!
var order = await context.Orders
    .Include(o => o.LineItems)
    .FirstOrDefaultAsync(o => o.OrderNumber == "ORD-2024-001");

var total = order!.LineItems.Sum(li => li.SubTotal);

// Додавання нового рядка
order.LineItems.Add(new OrderLineItem
{
    ProductSku = "HDMI-003", ProductName = "HDMI Cable",
    Quantity = 1, UnitPrice = 250m
});
await context.SaveChangesAsync();
// INSERT INTO OrderLineItems (OrderId, ProductSku, ...) VALUES (...)
OwnsMany і Include: На відміну від inline OwnsOne, OwnsManyзавжди зберігається в окремій таблиці. Без .Include(o => o.LineItems) колекція буде порожньою, не null — це може бути оманливим.

Вкладені Owned Types: Address → Coordinate

Owned Types можуть містити інші Owned Types — вкладені ієрархії об'єктів.

public class Coordinate
{
    public double Latitude  { get; set; }
    public double Longitude { get; set; }
}

public class DeliveryAddress
{
    public string Street     { get; set; } = string.Empty;
    public string City       { get; set; } = string.Empty;
    public string Country    { get; set; } = string.Empty;
    public string PostalCode { get; set; } = string.Empty;
    public Coordinate? GpsLocation { get; set; }  // вкладений Value Object
}

public class Store
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public DeliveryAddress Address { get; set; } = null!;
}
builder.OwnsOne(s => s.Address, addr =>
{
    addr.Property(a => a.Street).HasColumnName("Street").IsRequired().HasMaxLength(300);
    addr.Property(a => a.City).HasColumnName("City").IsRequired().HasMaxLength(100);
    addr.Property(a => a.Country).HasColumnName("Country").IsRequired().HasMaxLength(100);
    addr.Property(a => a.PostalCode).HasColumnName("PostalCode")
        .IsRequired().HasMaxLength(20).IsUnicode(false);

    // Вкладений OwnsOne: Coordinate всередині Address
    addr.OwnsOne(a => a.GpsLocation, gps =>
    {
        gps.Property(g => g.Latitude).HasColumnName("GpsLatitude").HasColumnType("float");
        gps.Property(g => g.Longitude).HasColumnName("GpsLongitude").HasColumnType("float");
    });
});

Результуюча таблиця:

CREATE TABLE [Stores] (
    [Id]           INT           NOT NULL IDENTITY,
    [Name]         NVARCHAR(200) NOT NULL,
    [Street]       NVARCHAR(300) NOT NULL,
    [City]         NVARCHAR(100) NOT NULL,
    [Country]      NVARCHAR(100) NOT NULL,
    [PostalCode]   VARCHAR(20)   NOT NULL,
    -- Coordinate (другий рівень вкладення)
    [GpsLatitude]  FLOAT         NULL,
    [GpsLongitude] FLOAT         NULL,
    CONSTRAINT [PK_Stores] PRIMARY KEY ([Id])
);

Всі три рівні ієрархії (StoreDeliveryAddressCoordinate) — в одній таблиці. Ніяких JOIN'ів.

// Запит через вкладений Owned Type
var storesInKyiv = await context.Stores
    .Where(s => s.Address.City == "Київ" && s.Address.GpsLocation != null)
    .Select(s => new { s.Name, s.Address.GpsLocation!.Latitude, s.Address.GpsLocation.Longitude })
    .ToListAsync();
// SQL: SELECT Name, GpsLatitude, GpsLongitude FROM Stores
//      WHERE City = 'Київ' AND GpsLatitude IS NOT NULL

Table Splitting: одна SQL-таблиця для кількох C#-класів

Table Splitting дозволяє ділити одну SQL-таблицю між кількома C#-класами. Це корисно, коли таблиця містить стовпці різної частоти доступу — «легкі» для списків і «важкі» для деталей.

// Легкий клас — для списків і пошуку
public class ProductSummary
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public string CategoryCode { get; set; } = string.Empty;
    public bool IsActive { get; set; }
    public ProductDetail? Detail { get; set; }
}

// Важкий клас — для сторінки деталей
public class ProductDetail
{
    public int    Id { get; set; }  // Той самий Id!
    public string Description { get; set; } = string.Empty;
    public string? FullSpecifications { get; set; }
    public string? SeoTitle { get; set; }
    public string? SeoDescription { get; set; }
    public byte[]? ManualPdf { get; set; }
    public ProductSummary Summary { get; set; } = null!;
}
public class ProductSummaryConfiguration : IEntityTypeConfiguration<ProductSummary>
{
    public void Configure(EntityTypeBuilder<ProductSummary> builder)
    {
        builder.ToTable("Products"); // Вказуємо ту ж таблицю!
        builder.HasKey(p => p.Id);
        builder.Property(p => p.Name).IsRequired().HasMaxLength(200);
        builder.Property(p => p.Price).HasPrecision(12, 2);
        builder.Property(p => p.CategoryCode).HasMaxLength(50);

        // 1:1 з shared PK
        builder.HasOne(p => p.Detail)
               .WithOne(d => d.Summary)
               .HasForeignKey<ProductDetail>(d => d.Id);
    }
}

public class ProductDetailConfiguration : IEntityTypeConfiguration<ProductDetail>
{
    public void Configure(EntityTypeBuilder<ProductDetail> builder)
    {
        builder.ToTable("Products"); // Та сама таблиця!
        builder.HasKey(d => d.Id);
        builder.Property(d => d.Description).HasColumnType("nvarchar(max)");
        builder.Property(d => d.FullSpecifications).HasColumnType("nvarchar(max)");
        builder.Property(d => d.SeoTitle).HasMaxLength(160);
        builder.Property(d => d.SeoDescription).HasMaxLength(320);
        builder.Property(d => d.ManualPdf).HasColumnType("varbinary(max)");
    }
}
// Список: лише «легкі» стовпці
var products = await context.Set<ProductSummary>()
    .Where(p => p.IsActive && p.CategoryCode == "LAPTOP")
    .OrderBy(p => p.Price)
    .ToListAsync();
// SQL: SELECT Id, Name, Price, CategoryCode, IsActive FROM Products
// (Description, PDF NOT завантажуються!)

// Деталі: підвантажуємо через Include
var productWithDetail = await context.Set<ProductSummary>()
    .Include(p => p.Detail)
    .FirstOrDefaultAsync(p => p.Id == productId);
// SQL: SELECT p.*, d.Description, d.SeoTitle, ...
//      FROM Products p LEFT JOIN Products d ON p.Id = d.Id
Table Splitting vs Owned Types: Table Splitting підходить коли «той самий реальний об'єкт, але різні C#-представлення». Owned Types підходять коли «C#-об'єкт є атрибутом/частиною іншого». Якщо ProductDetail описує той самий продукт — Table Splitting. Якщо Address є атрибутом Customer — Owned Types.

Entity Splitting: одна C#-сутність → кілька SQL-таблиць

Entity Splitting — дзеркальна операція. Дані однієї C#-сутності розподіляються між кількома SQL-таблицями. Корисно при інтеграції з legacy-схемою або з міркувань compliance (різні таблиці мають різний рівень доступу).

public class Employee
{
    public int Id { get; set; }
    // Таблиця Employees (основна)
    public string FirstName { get; set; } = string.Empty;
    public string LastName  { get; set; } = string.Empty;
    public string Email     { get; set; } = string.Empty;
    public string JobTitle  { get; set; } = string.Empty;
    // Таблиця EmployeeContacts
    public string? PhoneNumber   { get; set; }
    public string? HomeAddress   { get; set; }
    // Таблиця EmployeePayroll (обмежений доступ)
    public decimal Salary      { get; set; }
    public string  BankAccount { get; set; } = string.Empty;
    public string  TaxId       { get; set; } = string.Empty;
}
public class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
{
    public void Configure(EntityTypeBuilder<Employee> builder)
    {
        builder.HasKey(e => e.Id);
        builder.ToTable("Employees");
        builder.Property(e => e.FirstName).IsRequired().HasMaxLength(100);
        builder.Property(e => e.LastName).IsRequired().HasMaxLength(100);
        builder.Property(e => e.Email).IsRequired().HasMaxLength(320);
        builder.Property(e => e.JobTitle).HasMaxLength(200);

        // Розщеплення: EmployeeContacts
        builder.SplitToTable("EmployeeContacts", table =>
        {
            table.Property(e => e.PhoneNumber).HasMaxLength(30);
            table.Property(e => e.HomeAddress).HasMaxLength(500);
        });

        // Розщеплення: EmployeePayroll (чутливі дані)
        builder.SplitToTable("EmployeePayroll", table =>
        {
            table.Property(e => e.Salary).HasPrecision(12, 2);
            table.Property(e => e.BankAccount).HasMaxLength(50).IsUnicode(false);
            table.Property(e => e.TaxId).HasMaxLength(30).IsUnicode(false);
        });
    }
}

EF Core автоматично генерує JOIN при читанні:

SELECT e.[Id], e.[FirstName], e.[LastName], e.[Email], e.[JobTitle],
       ec.[PhoneNumber], ec.[HomeAddress],
       ep.[Salary], ep.[BankAccount], ep.[TaxId]
FROM [Employees] e
LEFT JOIN [EmployeeContacts] ec ON e.[Id] = ec.[Id]
LEFT JOIN [EmployeePayroll]  ep ON e.[Id] = ep.[Id]
WHERE e.[Id] = @id

Практичні завдання (Частина 1)

Рівень 1 — Базовий

Завдання 1.1: OwnsOne для контакту

Є клас Supplier (постачальник) з полями: Id, CompanyName, а також контактна особа: ContactFirstName, ContactLastName, ContactEmail, ContactPhone. Виокремте контактні дані в окремий Value Object ContactPerson і налаштуйте OwnsOne так, щоб стовпці в таблиці мали префікс Contact_.

Завдання 1.2: OwnsMany для аудиту

Клас Document має колекцію public ICollection<AuditEntry> AuditLog. Кожен AuditEntry містить: ActionType (enum: Created, Updated, Deleted, Viewed), PerformedBy (string), PerformedAt (DateTime), Notes (string?). Налаштуйте OwnsMany з таблицею DocumentAuditLog.

Завдання 1.3: Nullable OwnsOne

Для Job (Id, Title, SalaryRange?) де SalaryRange містить MinSalary і MaxSalary. Налаштуйте OwnsOne для необов'язкового SalaryRange?. Що відбувається у DDL — стовпці NOT NULL чи NULL?

Рівень 2 — Логіка

Завдання 2.1: Вкладені Owned Types

Реалізуйте модель Restaurant:

  • Address (вулиця, місто, країна) — Owned Type
  • Всередині AddressOpeningHours (Owned Type): MondayOpen, MondayClose (TimeOnly), і так само для всіх днів тижня

Всі дані в одній таблиці Restaurants. Напишіть запит, що знаходить всі ресторани, відкриті у понеділок після 09:00.

Завдання 2.2: Table Splitting для Product

Розробіть Table Splitting:

  • ProductCard: Id, Name, Price, ThumbnailUrl
  • ProductPage: Id, Description (nvarchar(max)), Specifications (nvarchar(max)) Обидва маппляться на таблицю Products. Напишіть два запити: список карток і деталі товару.

Рівень 3 — Архітектура

Завдання 3.1: DDD Aggregate з OwnsMany та інкапсуляцією

Реалізуйте Aggregate ShoppingCart:

  • Приватна колекція private readonly List<CartLine> _lines (backing field)
  • CartLine як Value Object: ProductId, ProductName, Quantity, UnitPrice
  • Методи: AddLine(productId, name, qty, price) — якщо ProductId існує, збільшуємо Quantity; RemoveLine(productId); Clear(); decimal Total { get; }
  • EF Core зберігає через OwnsMany до таблиці CartLines

Підсумок частини 1

Ця частина заклала фундамент для роботи зі складними типами:

  • Value Objects (DDD): теоретична основа Owned Types — об'єкти без ідентичності, рівність через значення, незмінність через init.
  • OwnsOne: вбудування Value Object у таблицю власника (inline) або в окрему таблицю (split). Inline-завантажується автоматично, split — через Include.
  • OwnsMany: колекція Value Objects — завжди в окремій таблиці, тіньовий PK, FK з CASCADE DELETE, обов'язковий Include.
  • Вкладені Owned Types: Store → DeliveryAddress → Coordinate — всі рівні ієрархії в одній таблиці, без JOIN'ів.
  • Table Splitting: одна SQL-таблиця, кілька C#-класів — для оптимізації завантаження «важких» стовпців.
  • Entity Splitting: одна C#-сутність, кілька SQL-таблиць — для legacy-схем або compliance.

Продовження у другій частині: Complex Types (EF Core 8+), Keyless Entity Types, порівняння всіх підходів.