Розглянемо типового замовника інтернет-магазину. У базі є таблиця 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.
Щоб правильно зрозуміти Owned Types, потрібно познайомитися з концепцією з Domain-Driven Design (DDD) — Value Object (об'єкт-значення).
Eric Evans у книзі «Domain-Driven Design» виділяє два фундаментальних типи доменних об'єктів.
Сутність визначається своєю ідентичністю, а не атрибутами. Два замовники з однаковим іменем «Іван Петренко» — різні сутності, бо мають різні ID. Навіть якщо помінялося ім'я, email, адреса — сутність залишається тою самою (той самий Id).
Характеристики Entity:
Id)Id, не через значення полівValue Object визначається своїми атрибутами. Дві адреси «Хрещатик 1, Київ» і «Хрещатик 1, Київ» — рівні, незалежно від того, чи це один і той самий об'єкт у пам'яті чи два різні. Адреса не має сенсу поза контекстом замовника або замовлення — вона є частиною іншого об'єкту.
Характеристики Value Object:
Id)Класичні приклади 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.
OwnsOne — конфігурація, що говорить EF Core: «цей об'єкт є частиною свого власника, він не є окремою сутністю». EF Core зберігає поля Owned Type у тій самій таблиці, що й власник (за замовчуванням), або в окремій таблиці за явного вказання.
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 без ToTable — inline embedding.
// Створення
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 = 'Київ'
Include. Якщо ж Owned Type розміщений в окремій таблиці (через ToTable) — додається JOIN при кожному запиті.Якщо 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 — аналог OwnsOne для колекцій. Він завжди вимагає окремої таблиці, оскільки один рядок власника пов'язаний з багатьма рядками Owned Type.
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 (...)
OwnsOne, OwnsManyзавжди зберігається в окремій таблиці. Без .Include(o => o.LineItems) колекція буде порожньою, не null — це може бути оманливим.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])
);
Всі три рівні ієрархії (Store → DeliveryAddress → Coordinate) — в одній таблиці. Ніяких 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#-класами. Це корисно, коли таблиця містить стовпці різної частоти доступу — «легкі» для списків і «важкі» для деталей.
// Легкий клас — для списків і пошуку
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
ProductDetail описує той самий продукт — Table Splitting. Якщо Address є атрибутом Customer — Owned Types.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: 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.1: Вкладені Owned Types
Реалізуйте модель Restaurant:
Address (вулиця, місто, країна) — Owned TypeAddress — OpeningHours (Owned Type): MondayOpen, MondayClose (TimeOnly), і так само для всіх днів тижняВсі дані в одній таблиці Restaurants. Напишіть запит, що знаходить всі ресторани, відкриті у понеділок після 09:00.
Завдання 2.2: Table Splitting для Product
Розробіть Table Splitting:
ProductCard: Id, Name, Price, ThumbnailUrlProductPage: Id, Description (nvarchar(max)), Specifications (nvarchar(max))
Обидва маппляться на таблицю Products. Напишіть два запити: список карток і деталі товару.Завдання 3.1: DDD Aggregate з OwnsMany та інкапсуляцією
Реалізуйте Aggregate ShoppingCart:
private readonly List<CartLine> _lines (backing field)CartLine як Value Object: ProductId, ProductName, Quantity, UnitPriceAddLine(productId, name, qty, price) — якщо ProductId існує, збільшуємо Quantity; RemoveLine(productId); Clear(); decimal Total { get; }OwnsMany до таблиці CartLinesЦя частина заклала фундамент для роботи зі складними типами:
init.Include.Include.Store → DeliveryAddress → Coordinate — всі рівні ієрархії в одній таблиці, без JOIN'ів.Продовження у другій частині: Complex Types (EF Core 8+), Keyless Entity Types, порівняння всіх підходів.
Властивості — Value Comparers, Generators, Shadow Properties (Частина 2)
Продовження розбору конфігурації властивостей EF Core — Value Comparers для правильного Change Tracking, Value Generators, Default Values, Computed Columns, Shadow Properties, Backing Fields та Temporal Tables.
Складні типи — Complex Types, Keyless Entities, Порівняння (Частина 2)
Complex Types (EF Core 8+) як нові Value Objects без ідентичності, Keyless Entity Types для Views і Raw SQL, практичне DDD-моделювання та матриця вибору між Owned Types, Complex Types і Value Converters.