Databasplanering för C# applikationer med Entity Framework Core
OBS: Detta dokument täcker Entity Framework Core (EF Core). För SQL/ADO.NET-baserad databasprogrammering, se dokumentet “Databasplanering för C# (SQL/ADO.NET)”.
När du planerar en databas för en C#-applikation finns det specifika överväganden som skiljer sig från generell databasdesign. Detta dokument visar hur du tänker databas + C# från start, hur du mappar mellan databas och objekt, och vilka patterns och verktyg som gör livet enklare.
Innehållsförteckning
- Introduktion
- C#-specifika överväganden
- Object-Relational Mapping (ORM)
- Entity Framework Core
- Dapper - Micro ORM
- ADO.NET - Direkt databasaccess
- Repository Pattern
- Unit of Work Pattern
- Migrationsstrategi
- Praktiskt exempel: E-handel
- Best practices
- Vanliga fallgropar
- Slutsats
- TL;DR
Introduktion
C# är ett starkt typat, objektorienterat språk. Databaser är relationella och schemabaserade. Hur får vi dessa två världar att samarbeta smidigt?
Impedance Mismatch kallas problemet där objektorienterad kod (C#) inte matchar relationell data (SQL):
// C#: Objekt med referenser
class Customer {
public int Id { get; set; }
public string Name { get; set; }
public List<Order> Orders { get; set; } // Lista av objekt
}
class Order {
public int Id { get; set; }
public Customer Customer { get; set; } // Referens till objekt
}
-- SQL: Tabeller med foreign keys
CREATE TABLE Customers (
CustomerId INTEGER PRIMARY KEY,
Name TEXT
);
CREATE TABLE Orders (
OrderId INTEGER PRIMARY KEY,
CustomerId INTEGER, -- Bara ett ID, inte hela objektet
FOREIGN KEY (CustomerId) REFERENCES Customers(CustomerId)
);
Lösningen? Verktyg och patterns som överbryggar gapet!
C#-specifika överväganden
1. Namnkonventioner
C# konventioner:
- PascalCase för klasser:
Customer,OrderItem - PascalCase för properties:
FirstName,IsActive - camelCase för privata fields:
_customerId
SQL konventioner (varies):
- PascalCase:
Customers,FirstName(SQL Server standard) - snake_case:
customers,first_name(PostgreSQL standard) - UPPERCASE:
CUSTOMERS,FIRST_NAME(gammal skola)
Best practice för C#: Använd PascalCase i både C# och databas för konsistens!
// C# klass
public class Customer {
public int CustomerId { get; set; }
public string FirstName { get; set; }
}
-- Databas (samma namn!)
CREATE TABLE Customers (
CustomerId INTEGER PRIMARY KEY,
FirstName TEXT NOT NULL
);
2. Datatyp-mappning
| C# typ | SQLite typ | SQL Server typ | Notering |
|---|---|---|---|
int | INTEGER | INT | 32-bit heltal |
long | INTEGER | BIGINT | 64-bit heltal |
string | TEXT | NVARCHAR(MAX) | UTF-16 i C#, varies i DB |
bool | INTEGER | BIT | SQLite: 0/1 |
DateTime | TEXT | DATETIME2 | ISO 8601 i SQLite |
decimal | REAL | DECIMAL(18,2) | För pengar! |
byte[] | BLOB | VARBINARY(MAX) | Binär data |
Guid | TEXT | UNIQUEIDENTIFIER | UUID |
Viktigt för pengar:
// ALLTID decimal för pengar i C#!
public decimal Price { get; set; }
// INTE float eller double (avrundningsfel!)
public float Price { get; set; } // ❌ DÅLIGT!
3. Null-hantering
C# har nullable reference types (C# 8+):
#nullable enable
public class Customer {
public int Id { get; set; }
public string Name { get; set; } // NOT NULL i DB
public string? MiddleName { get; set; } // NULL OK i DB
public DateTime CreatedAt { get; set; } // NOT NULL
public DateTime? DeletedAt { get; set; } // NULL OK (soft delete)
}
Motsvarande SQL:
CREATE TABLE Customers (
Id INTEGER PRIMARY KEY,
Name TEXT NOT NULL, -- Matchar non-nullable string
MiddleName TEXT, -- Matchar nullable string?
CreatedAt TEXT NOT NULL, -- Matchar DateTime
DeletedAt TEXT -- Matchar DateTime?
);
4. Collection properties
C# klasser kan ha listor, men databaser kan inte:
public class Customer {
public int Id { get; set; }
public string Name { get; set; }
// Navigation property (inte en DB-kolumn!)
public List<Order> Orders { get; set; } = new();
}
Detta mappas till foreign key relation:
-- INGEN "Orders"-kolumn i Customers!
CREATE TABLE Orders (
OrderId INTEGER PRIMARY KEY,
CustomerId INTEGER NOT NULL, -- Foreign key
FOREIGN KEY (CustomerId) REFERENCES Customers(CustomerId)
);
Object-Relational Mapping (ORM)
ORM = verktyg som automatiskt konverterar mellan objekt (C#) och tabeller (SQL).
Fördelar med ORM
✅ Mindre boilerplate-kod
// Utan ORM (ADO.NET)
using (var connection = new SQLiteConnection(connectionString)) {
connection.Open();
var command = new SQLiteCommand("SELECT * FROM Customers WHERE CustomerId = @id", connection);
command.Parameters.AddWithValue("@id", id);
using (var reader = command.ExecuteReader()) {
if (reader.Read()) {
return new Customer {
CustomerId = reader.GetInt32(0),
Name = reader.GetString(1),
Email = reader.GetString(2)
// ... många fler rader
};
}
}
}
// Med ORM (Entity Framework)
return dbContext.Customers.Find(id); // EN rad!
✅ Automatisk mapping
- Konverterar automatiskt mellan C# typer och SQL typer
- Hanterar NULL-värden
- Laddar relaterade objekt (navigation properties)
✅ LINQ support
// Skriv queries i C# istället för SQL!
var activeCustomers = dbContext.Customers
.Where(c => c.IsActive)
.OrderBy(c => c.Name)
.ToList();
// ORM genererar SQL automatiskt:
// SELECT * FROM Customers WHERE IsActive = 1 ORDER BY Name
✅ Database provider-oberoende
// Samma C#-kod fungerar med SQLite, SQL Server, PostgreSQL, etc!
// Byt bara connection string och provider
Nackdelar med ORM
❌ Prestanda overhead
- ORM genererar ofta suboptimal SQL
- Kan göra fler queries än nödvändigt (N+1 problem)
❌ Lärkurva
- Måste lära sig ORM:ens quirks
- “Magic” kan dölja vad som faktiskt händer
❌ Mindre kontroll
- Svårt att optimera komplexa queries
- Kan vara svårt att göra exakt den SQL du vill
Best practice: Använd ORM för 80% (CRUD), raw SQL för 20% (komplexa queries, rapporter)
Entity Framework Core
Entity Framework Core (EF Core) är Microsofts officiella ORM för .NET.
Installation
# Install EF Core och SQLite provider
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Tools
Två approaches: Code First vs Database First
Code First (Rekommenderat för nya projekt)
Du skriver C# klasser först, EF genererar databas.
1. Skapa entities:
public class Customer {
public int CustomerId { get; set; } // Primary key (ID eller ClassNameId)
public string Name { get; set; } = "";
public string Email { get; set; } = "";
// Navigation property
public List<Order> Orders { get; set; } = new();
}
public class Order {
public int OrderId { get; set; }
public int CustomerId { get; set; } // Foreign key
public DateTime OrderDate { get; set; }
// Navigation property
public Customer Customer { get; set; } = null!;
public List<OrderItem> OrderItems { get; set; } = new();
}
public class OrderItem {
public int OrderItemId { get; set; }
public int OrderId { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
public Order Order { get; set; } = null!;
public Product Product { get; set; } = null!;
}
public class Product {
public int ProductId { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
public int Stock { get; set; }
public List<OrderItem> OrderItems { get; set; } = new();
}
2. Skapa DbContext:
using Microsoft.EntityFrameworkCore;
public class ShopDbContext : DbContext {
public DbSet<Customer> Customers { get; set; } = null!;
public DbSet<Order> Orders { get; set; } = null!;
public DbSet<OrderItem> OrderItems { get; set; } = null!;
public DbSet<Product> Products { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder options) {
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var dbPath = Path.Combine(documentsPath, "shop.db");
options.UseSqlite($"Data Source={dbPath}");
}
protected override void OnModelCreating(ModelBuilder modelBuilder) {
// Konfigurera relationer och constraints
modelBuilder.Entity<Customer>(entity => {
entity.HasKey(e => e.CustomerId);
entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
entity.Property(e => e.Email).IsRequired();
entity.HasIndex(e => e.Email).IsUnique();
});
modelBuilder.Entity<Order>(entity => {
entity.HasKey(e => e.OrderId);
entity.HasOne(e => e.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(e => e.CustomerId);
});
modelBuilder.Entity<Product>(entity => {
entity.Property(e => e.Price).HasColumnType("decimal(18,2)");
});
// Seed data (initial data)
modelBuilder.Entity<Product>().HasData(
new Product { ProductId = 1, Name = "Laptop", Price = 9999, Stock = 10 },
new Product { ProductId = 2, Name = "Mouse", Price = 299, Stock = 50 }
);
}
}
3. Skapa databas med migrations:
# Skapa första migration
dotnet ef migrations add InitialCreate
# Applicera till databas
dotnet ef database update
EF Core skapar nu automatiskt:
- Tabeller för varje DbSet
- Primary keys
- Foreign keys
- Indexes
- Seed data
Database First (För befintliga databaser)
Du har redan en databas, EF genererar C# klasser.
# Generera klasser från befintlig databas
dotnet ef dbcontext scaffold "Data Source=shop.db" Microsoft.EntityFrameworkCore.Sqlite
CRUD operationer med EF Core
using var db = new ShopDbContext();
// CREATE
var customer = new Customer {
Name = "Anna Andersson",
Email = "anna@example.com"
};
db.Customers.Add(customer);
db.SaveChanges(); // Kör INSERT
// READ
var allCustomers = db.Customers.ToList();
var customer = db.Customers.Find(1); // Hämta med primary key
var annaCustomers = db.Customers
.Where(c => c.Name.StartsWith("Anna"))
.ToList();
// UPDATE
var customer = db.Customers.Find(1);
customer.Email = "anna.new@example.com";
db.SaveChanges(); // Kör UPDATE
// DELETE
var customer = db.Customers.Find(1);
db.Customers.Remove(customer);
db.SaveChanges(); // Kör DELETE
Eager Loading (ladda relaterad data)
// Ladda kund MED alla orders
var customer = db.Customers
.Include(c => c.Orders) // JOIN med Orders
.FirstOrDefault(c => c.CustomerId == 1);
// Ladda kund med orders OCH order items
var customer = db.Customers
.Include(c => c.Orders)
.ThenInclude(o => o.OrderItems)
.FirstOrDefault(c => c.CustomerId == 1);
// Nu kan vi använda navigation properties:
Console.WriteLine($"{customer.Name} har {customer.Orders.Count} beställningar");
foreach (var order in customer.Orders) {
Console.WriteLine($" Order {order.OrderId}: {order.OrderItems.Count} items");
}
Migrations
Migrations = versionskontroll för databas-schema.
# Skapa ny migration
dotnet ef migrations add AddProductCategory
# Se SQL som kommer köras
dotnet ef migrations script
# Applicera migration
dotnet ef database update
# Ångra senaste migration
dotnet ef database update PreviousMigrationName
Migration-fil exempel:
public partial class AddProductCategory : Migration {
protected override void Up(MigrationBuilder migrationBuilder) {
migrationBuilder.AddColumn<string>(
name: "Category",
table: "Products",
type: "TEXT",
nullable: true);
migrationBuilder.Sql(
"UPDATE Products SET Category = 'Electronics' WHERE ProductId IN (1,2)");
}
protected override void Down(MigrationBuilder migrationBuilder) {
migrationBuilder.DropColumn(
name: "Category",
table: "Products");
}
}
Fördjupning: Entity Framework Core (praktiska detaljer)
Transaktioner och konsistens
- Använd transaktioner vid flera skrivoperationer som måste vara atomiska. I EF Core:
using var tx = await context.Database.BeginTransactionAsync();
try {
// flera SaveChanges eller ändringar
await context.SaveChangesAsync();
await tx.CommitAsync();
}
catch {
await tx.RollbackAsync();
throw;
}
- För enkel UnitOfWork räcker oftast att kalla SaveChanges en gång innan Commit; men för långa operationer använd explicit transaction.
Konkurrenshantering (Concurrency)
- Använd optimistisk låsning med en “rowversion”/timestamp eller concurrency token:
public class Product {
public int ProductId { get; set; }
public decimal Price { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; } = null!;
}
- Hantera DbUpdateConcurrencyException genom att läsa om värden, merge:a och försöka igen eller informera användaren.
Prestanda: vanliga tekniker
- AsNoTracking för read-only queries: .AsNoTracking()
- Projektion till DTO för att bara hämta nödvändiga kolumner: .Select(c => new CustomerDto { … })
- Eager loading (Include), men undvik Include av stora collectioner om du bara behöver en count.
- Kompilerade frågor för högtrafikerade queries: EF.CompileQuery(…)
- Batch-inserts / SaveChanges i lämpliga storlekar (t.ex. 500 poster per SaveChanges) för bulk-operations.
- Undvik N+1 genom att analysera SQL (Use logging, profiler).
Logging och diagnostik
- Aktivera loggning vid utveckling för att se genererade SQL och parametervärden:
optionsBuilder
.UseSqlite($"Data Source={dbPath}")
.EnableSensitiveDataLogging() // var försiktig i prod
.LogTo(Console.WriteLine, LogLevel.Information);
- Titta på Query plan / EXPLAIN om en query är långsam.
Avancerad mapping
- Value Conversions: konvertera property-typer (t.ex. enum -> string):
modelBuilder.Entity<MyEntity>()
.Property(e => e.Status)
.HasConversion<string>();
- Owned types / Value objects för inbäddade data utan egen tabell:
[Owned]
public class Address { public string Street { get; set; } = ""; }
- Shadow properties för metadata som du inte vill ha i entity-klassen:
modelBuilder.Entity<Customer>().Property<DateTime>("LastSynced");
Dapper - Micro ORM
Dapper är ett “micro ORM” - mindre än EF men snabbare.
Installation
dotnet add package Dapper
dotnet add package System.Data.SQLite
Varför Dapper?
✅ Mycket snabbare än EF (nästan lika snabbt som raw ADO.NET) ✅ Enkel mapping från SQL till objekt ✅ Full kontroll över SQL ✅ Liten (~50 KB vs EF’s ~10 MB)
❌ Ingen change tracking (måste manuellt UPDATE) ❌ Ingen automatisk migration ❌ Måste skriva SQL själv
CRUD med Dapper
using Dapper;
using System.Data.SQLite;
var connectionString = "Data Source=shop.db";
// CREATE
using (var connection = new SQLiteConnection(connectionString)) {
var sql = "INSERT INTO Customers (Name, Email) VALUES (@Name, @Email)";
var rowsAffected = connection.Execute(sql, new {
Name = "Anna",
Email = "anna@example.com"
});
}
// READ (single)
using (var connection = new SQLiteConnection(connectionString)) {
var sql = "SELECT * FROM Customers WHERE CustomerId = @Id";
var customer = connection.QuerySingleOrDefault<Customer>(sql, new { Id = 1 });
}
// READ (multiple)
using (var connection = new SQLiteConnection(connectionString)) {
var sql = "SELECT * FROM Customers WHERE Name LIKE @Search";
var customers = connection.Query<Customer>(sql, new { Search = "Anna%" }).ToList();
}
// UPDATE
using (var connection = new SQLiteConnection(connectionString)) {
var sql = "UPDATE Customers SET Email = @Email WHERE CustomerId = @Id";
connection.Execute(sql, new {
Email = "anna.new@example.com",
Id = 1
});
}
// DELETE
using (var connection = new SQLiteConnection(connectionString)) {
var sql = "DELETE FROM Customers WHERE CustomerId = @Id";
connection.Execute(sql, new { Id = 1 });
}
Multi-mapping (JOINs)
var sql = @"
SELECT c.*, o.*
FROM Customers c
LEFT JOIN Orders o ON c.CustomerId = o.CustomerId
WHERE c.CustomerId = @Id";
using (var connection = new SQLiteConnection(connectionString)) {
var customerDictionary = new Dictionary<int, Customer>();
var customers = connection.Query<Customer, Order, Customer>(
sql,
(customer, order) => {
if (!customerDictionary.TryGetValue(customer.CustomerId, out var customerEntry)) {
customerEntry = customer;
customerEntry.Orders = new List<Order>();
customerDictionary.Add(customerEntry.CustomerId, customerEntry);
}
if (order != null) {
customerEntry.Orders.Add(order);
}
return customerEntry;
},
new { Id = 1 },
splitOn: "OrderId"
).Distinct();
return customerDictionary.Values.FirstOrDefault();
}
ADO.NET - Direkt databasaccess
ADO.NET är det lågnivå-API som både EF och Dapper använder under huven.
När använda raw ADO.NET?
✅ Maximum prestanda (ingen ORM overhead) ✅ Full kontroll över SQL och mappning ✅ Komplexa queries som ORM:er inte klarar ✅ Bulk operations (tusentals rader)
❌ Mycket boilerplate-kod ❌ Måste hantera SqlDataReader manuellt ❌ Risk för SQL injection om man inte är försiktig
Exempel med ADO.NET
Se tidigare övningar för fullständiga exempel. Här är kort sammanfattning:
using (var connection = new SQLiteConnection(connectionString)) {
connection.Open();
var sql = "SELECT * FROM Customers WHERE CustomerId = @Id";
using (var command = new SQLiteCommand(sql, connection)) {
command.Parameters.AddWithValue("@Id", 1);
using (var reader = command.ExecuteReader()) {
if (reader.Read()) {
return new Customer {
CustomerId = reader.GetInt32(reader.GetOrdinal("CustomerId")),
Name = reader.GetString(reader.GetOrdinal("Name")),
Email = reader.GetString(reader.GetOrdinal("Email"))
};
}
}
}
}
Repository Pattern
Repository Pattern = abstrahera databasaccess bakom ett interface.
Varför Repository?
✅ Testbarhet - Mocka repository i unit tests ✅ Separation of Concerns - Business logic vet inte om databas ✅ Enkelt byta implementation (EF → Dapper → MongoDB)
Implementation
// 1. Interface
public interface ICustomerRepository {
Customer? GetById(int id);
IEnumerable<Customer> GetAll();
IEnumerable<Customer> Search(string name);
void Add(Customer customer);
void Update(Customer customer);
void Delete(int id);
}
// 2. Implementation med EF Core
public class CustomerRepository : ICustomerRepository {
private readonly ShopDbContext _context;
public CustomerRepository(ShopDbContext context) {
_context = context;
}
public Customer? GetById(int id) {
return _context.Customers
.Include(c => c.Orders)
.FirstOrDefault(c => c.CustomerId == id);
}
public IEnumerable<Customer> GetAll() {
return _context.Customers.ToList();
}
public IEnumerable<Customer> Search(string name) {
return _context.Customers
.Where(c => c.Name.Contains(name))
.ToList();
}
public void Add(Customer customer) {
_context.Customers.Add(customer);
_context.SaveChanges();
}
public void Update(Customer customer) {
_context.Customers.Update(customer);
_context.SaveChanges();
}
public void Delete(int id) {
var customer = _context.Customers.Find(id);
if (customer != null) {
_context.Customers.Remove(customer);
_context.SaveChanges();
}
}
}
// 3. Användning
public class CustomerService {
private readonly ICustomerRepository _repository;
public CustomerService(ICustomerRepository repository) {
_repository = repository;
}
public void RegisterNewCustomer(string name, string email) {
// Validering
if (string.IsNullOrWhiteSpace(name)) {
throw new ArgumentException("Name required");
}
// Business logic
var customer = new Customer {
Name = name,
Email = email,
CreatedAt = DateTime.Now
};
// Spara via repository
_repository.Add(customer);
}
}
Unit of Work Pattern
Unit of Work = gruppera flera repository-operationer i en transaktion.
Problem utan Unit of Work
public void PlaceOrder(int customerId, List<OrderItem> items) {
var order = new Order {
CustomerId = customerId,
OrderDate = DateTime.Now
};
orderRepository.Add(order); // COMMIT 1
foreach (var item in items) {
item.OrderId = order.OrderId;
orderItemRepository.Add(item); // COMMIT 2, 3, 4...
}
productRepository.DecreaseStock(items); // COMMIT N
// Vad händer om DecreaseStock kraschar?
// Order är redan sparad men stock inte uppdaterad!
}
Lösning med Unit of Work
public interface IUnitOfWork : IDisposable {
ICustomerRepository Customers { get; }
IOrderRepository Orders { get; }
IProductRepository Products { get; }
void SaveChanges();
void BeginTransaction();
void Commit();
void Rollback();
}
public class UnitOfWork : IUnitOfWork {
private readonly ShopDbContext _context;
private IDbContextTransaction? _transaction;
public UnitOfWork(ShopDbContext context) {
_context = context;
Customers = new CustomerRepository(context);
Orders = new OrderRepository(context);
Products = new ProductRepository(context);
}
public ICustomerRepository Customers { get; }
public IOrderRepository Orders { get; }
public IProductRepository Products { get; }
public void SaveChanges() {
_context.SaveChanges();
}
public void BeginTransaction() {
_transaction = _context.Database.BeginTransaction();
}
public void Commit() {
try {
_context.SaveChanges();
_transaction?.Commit();
}
catch {
Rollback();
throw;
}
}
public void Rollback() {
_transaction?.Rollback();
}
public void Dispose() {
_transaction?.Dispose();
_context.Dispose();
}
}
// Användning
public void PlaceOrder(int customerId, List<OrderItem> items) {
using var uow = new UnitOfWork(new ShopDbContext());
try {
uow.BeginTransaction();
var order = new Order {
CustomerId = customerId,
OrderDate = DateTime.Now
};
uow.Orders.Add(order);
foreach (var item in items) {
item.OrderId = order.OrderId;
uow.Orders.AddItem(item);
uow.Products.DecreaseStock(item.ProductId, item.Quantity);
}
uow.Commit(); // ALLT eller INGET sparas!
}
catch {
uow.Rollback();
throw;
}
}
Migrationsstrategi
Development → Production
1. Lokal utveckling:
# Skapa migration
dotnet ef migrations add AddNewFeature
# Testa lokalt
dotnet ef database update
2. Version control:
git add Migrations/
git commit -m "Add migration for new feature"
git push
3. CI/CD pipeline:
# I deployment script
dotnet ef database update --connection "$PRODUCTION_CONNECTION_STRING"
Rollback-strategi
Migration med Up och Down:
public partial class AddProductCategory : Migration {
protected override void Up(MigrationBuilder migrationBuilder) {
migrationBuilder.AddColumn<string>("Category", "Products");
}
protected override void Down(MigrationBuilder migrationBuilder) {
migrationBuilder.DropColumn("Category", "Products");
}
}
Rollback:
# Visa alla migrations
dotnet ef migrations list
# Gå tillbaka till specifik migration
dotnet ef database update PreviousMigrationName
Data migration
Scenario: Dela upp “Name” i “FirstName” och “LastName”
public partial class SplitName : Migration {
protected override void Up(MigrationBuilder migrationBuilder) {
// 1. Lägg till nya kolumner
migrationBuilder.AddColumn<string>("FirstName", "Customers", nullable: true);
migrationBuilder.AddColumn<string>("LastName", "Customers", nullable: true);
// 2. Migrera data
migrationBuilder.Sql(@"
UPDATE Customers
SET FirstName = SUBSTR(Name, 1, INSTR(Name, ' ') - 1),
LastName = SUBSTR(Name, INSTR(Name, ' ') + 1)
WHERE Name LIKE '% %'
");
migrationBuilder.Sql(@"
UPDATE Customers
SET FirstName = Name,
LastName = ''
WHERE Name NOT LIKE '% %'
");
// 3. Ta bort gamla kolumnen
migrationBuilder.DropColumn("Name", "Customers");
}
protected override void Down(MigrationBuilder migrationBuilder) {
migrationBuilder.AddColumn<string>("Name", "Customers");
migrationBuilder.Sql(@"
UPDATE Customers
SET Name = FirstName || ' ' || LastName
");
migrationBuilder.DropColumn("FirstName", "Customers");
migrationBuilder.DropColumn("LastName", "Customers");
}
}
Praktiskt exempel: E-handel
Fullständig implementation med alla patterns:
1. Entities
public class Customer {
public int CustomerId { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public DateTime CreatedAt { get; set; }
public bool IsActive { get; set; } = true;
public List<Order> Orders { get; set; } = new();
}
public class Product {
public int ProductId { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public decimal Price { get; set; }
public int Stock { get; set; }
public string Category { get; set; } = "";
public List<OrderItem> OrderItems { get; set; } = new();
}
public class Order {
public int OrderId { get; set; }
public int CustomerId { get; set; }
public DateTime OrderDate { get; set; }
public string Status { get; set; } = "Pending"; // Pending, Confirmed, Shipped, Delivered
public Customer Customer { get; set; } = null!;
public List<OrderItem> OrderItems { get; set; } = new();
public decimal GetTotal() => OrderItems.Sum(i => i.Price * i.Quantity);
}
public class OrderItem {
public int OrderItemId { get; set; }
public int OrderId { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; } // Historiskt pris vid beställning
public Order Order { get; set; } = null!;
public Product Product { get; set; } = null!;
}
2. DbContext
public class ShopDbContext : DbContext {
public DbSet<Customer> Customers { get; set; } = null!;
public DbSet<Product> Products { get; set; } = null!;
public DbSet<Order> Orders { get; set; } = null!;
public DbSet<OrderItem> OrderItems { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder options) {
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var dbPath = Path.Combine(documentsPath, "databases", "shop.db");
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
options.UseSqlite($"Data Source={dbPath}");
}
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.Entity<Customer>(entity => {
entity.HasKey(e => e.CustomerId);
entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
entity.Property(e => e.Email).IsRequired().HasMaxLength(100);
entity.HasIndex(e => e.Email).IsUnique();
entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')");
});
modelBuilder.Entity<Product>(entity => {
entity.HasKey(e => e.ProductId);
entity.Property(e => e.Price).HasColumnType("decimal(18,2)");
entity.Property(e => e.Stock).HasDefaultValue(0);
entity.HasCheckConstraint("CK_Product_Price", "Price >= 0");
entity.HasCheckConstraint("CK_Product_Stock", "Stock >= 0");
});
modelBuilder.Entity<Order>(entity => {
entity.HasKey(e => e.OrderId);
entity.Property(e => e.OrderDate).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.Status).HasDefaultValue("Pending");
entity.HasOne(e => e.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(e => e.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<OrderItem>(entity => {
entity.HasKey(e => e.OrderItemId);
entity.Property(e => e.Price).HasColumnType("decimal(18,2)");
entity.HasOne(e => e.Order)
.WithMany(o => o.OrderItems)
.HasForeignKey(e => e.OrderId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Product)
.WithMany(p => p.OrderItems)
.HasForeignKey(e => e.ProductId)
.OnDelete(DeleteBehavior.Restrict);
});
// Seed data
modelBuilder.Entity<Product>().HasData(
new Product { ProductId = 1, Name = "Laptop", Price = 9999, Stock = 10, Category = "Electronics" },
new Product { ProductId = 2, Name = "Mouse", Price = 299, Stock = 50, Category = "Electronics" },
new Product { ProductId = 3, Name = "Desk", Price = 2499, Stock = 5, Category = "Furniture" }
);
}
}
3. Repository Implementation
public interface IProductRepository {
Product? GetById(int id);
IEnumerable<Product> GetAll();
IEnumerable<Product> GetByCategory(string category);
void Add(Product product);
void Update(Product product);
bool DecreaseStock(int productId, int quantity);
}
public class ProductRepository : IProductRepository {
private readonly ShopDbContext _context;
public ProductRepository(ShopDbContext context) {
_context = context;
}
public Product? GetById(int id) {
return _context.Products.Find(id);
}
public IEnumerable<Product> GetAll() {
return _context.Products.ToList();
}
public IEnumerable<Product> GetByCategory(string category) {
return _context.Products
.Where(p => p.Category == category)
.ToList();
}
public void Add(Product product) {
_context.Products.Add(product);
_context.SaveChanges();
}
public void Update(Product product) {
_context.Products.Update(product);
_context.SaveChanges();
}
public bool DecreaseStock(int productId, int quantity) {
var product = _context.Products.Find(productId);
if (product == null || product.Stock < quantity) {
return false;
}
product.Stock -= quantity;
_context.SaveChanges();
return true;
}
}
4. Service Layer
public class OrderService {
private readonly IUnitOfWork _unitOfWork;
public OrderService(IUnitOfWork unitOfWork) {
_unitOfWork = unitOfWork;
}
public Result<Order> PlaceOrder(int customerId, List<(int ProductId, int Quantity)> items) {
try {
_unitOfWork.BeginTransaction();
// Validera kund
var customer = _unitOfWork.Customers.GetById(customerId);
if (customer == null) {
return Result<Order>.Failure("Customer not found");
}
// Skapa order
var order = new Order {
CustomerId = customerId,
OrderDate = DateTime.Now,
Status = "Pending"
};
// Lägg till items
foreach (var (productId, quantity) in items) {
var product = _unitOfWork.Products.GetById(productId);
if (product == null) {
_unitOfWork.Rollback();
return Result<Order>.Failure($"Product {productId} not found");
}
if (product.Stock < quantity) {
_unitOfWork.Rollback();
return Result<Order>.Failure($"Insufficient stock for {product.Name}");
}
var orderItem = new OrderItem {
ProductId = productId,
Quantity = quantity,
Price = product.Price // Spara historiskt pris
};
order.OrderItems.Add(orderItem);
// Minska lagersaldo
if (!_unitOfWork.Products.DecreaseStock(productId, quantity)) {
_unitOfWork.Rollback();
return Result<Order>.Failure("Failed to decrease stock");
}
}
_unitOfWork.Orders.Add(order);
_unitOfWork.Commit();
return Result<Order>.Success(order);
}
catch (Exception ex) {
_unitOfWork.Rollback();
return Result<Order>.Failure($"Error: {ex.Message}");
}
}
}
// Result pattern för tydlig felhantering
public class Result<T> {
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(bool isSuccess, T? value, string? error) {
IsSuccess = isSuccess;
Value = value;
Error = error;
}
public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Failure(string error) => new(false, default, error);
}
Best practices
1. Använd async/await för I/O operationer
// SYNKRON (blockerar tråd)
public Customer GetCustomer(int id) {
return _context.Customers.Find(id);
}
// ASYNKRON (frigör tråd under I/O)
public async Task<Customer?> GetCustomerAsync(int id) {
return await _context.Customers.FindAsync(id);
}
// Användning
var customer = await GetCustomerAsync(1);
2. Dispose DbContext korrekt
// DÅLIGT ❌ - DbContext lever för länge
public class MyService {
private readonly ShopDbContext _context = new(); // Skapas EN gång
public void DoWork() {
_context.Customers.Add(...); // Bygger upp massa ändringar i minnet
}
}
// BRA ✅ - DbContext per operation
public class MyService {
public void DoWork() {
using var context = new ShopDbContext(); // Skapas och dispose:as
context.Customers.Add(...);
context.SaveChanges();
} // Dispose här
}
// BÄTTRE ✅ - Dependency Injection
public class MyService {
private readonly IDbContextFactory<ShopDbContext> _contextFactory;
public MyService(IDbContextFactory<ShopDbContext> contextFactory) {
_contextFactory = contextFactory;
}
public void DoWork() {
using var context = _contextFactory.CreateDbContext();
context.Customers.Add(...);
context.SaveChanges();
}
}
3. Använd DTOs för API responses
// DÅLIGT ❌ - Returnera entity direkt
public Customer GetCustomer(int id) {
return _context.Customers
.Include(c => c.Orders) // Laddar ALLA orders!
.Find(id);
}
// BRA ✅ - Returnera DTO med bara det som behövs
public class CustomerDto {
public int Id { get; set; }
public string Name { get; set; }
public int OrderCount { get; set; }
}
public CustomerDto GetCustomer(int id) {
return _context.Customers
.Where(c => c.CustomerId == id)
.Select(c => new CustomerDto {
Id = c.CustomerId,
Name = c.Name,
OrderCount = c.Orders.Count
})
.FirstOrDefault();
}
4. Optimera queries
// DÅLIGT ❌ - N+1 problem
var customers = _context.Customers.ToList();
foreach (var customer in customers) {
Console.WriteLine($"{customer.Name}: {customer.Orders.Count} orders");
// Orders laddas separat för VARJE kund = N+1 queries!
}
// BRA ✅ - Eager loading
var customers = _context.Customers
.Include(c => c.Orders) // EN query med JOIN
.ToList();
foreach (var customer in customers) {
Console.WriteLine($"{customer.Name}: {customer.Orders.Count} orders");
}
// BÄTTRE ✅ - Projicera till DTO
var customers = _context.Customers
.Select(c => new {
c.Name,
OrderCount = c.Orders.Count
})
.ToList();
5. Använd migrations för all schema-ändring
# DÅLIGT ❌ - Manuell SQL i produktion
# "Jag loggar bara in på prod-databasen och kör ALTER TABLE..."
// BRA ✅ - Migration
dotnet ef migrations add AddEmailToCustomer
dotnet ef database update
// Versioner, kan rullas tillbaka, spåras i git!
Fördjupning: Migrations — praktiska råd och data migration
- Testa migrations lokalt i en kopia av real data eller en representativ datamängd innan produktionskörning.
- Dela upp stora data-migrationer i flera steg: 1) lägg till kolumn (nullable), 2) migrera data i batcher (skript/ETL), 3) markera kolumn NOT NULL och ta bort gamla kolumner.
- Undvik långa blockerande ALTER TABLE i produktionsfönster; använd idempotenta och kortlivade ändringar där möjligt.
- Generera SQL-script för produktion och kör dem via DBA/CI-pipeline istället för att låta applikationen köra migrations automatiskt.
- När du använder migrationBuilder.Sql(…) för stora dataoperationer — gör det i små transaktioner eller kör externa jobs.
Exempel: säker data-migration i migration:
protected override void Up(MigrationBuilder migrationBuilder) {
migrationBuilder.AddColumn<string>("FirstName", "Customers", nullable: true);
// NOTERA: migrera i appkod eller separata script i batches istället för stora UPDATE i migration
}
Säkerhet, drift och testning
Säkerhet och best practice
- Alltid parameterisera queries (EF och Dapper gör detta automatiskt om du använder parametriserade API:er).
- Minimera DB-privilegier: ge appen endast de rättigheter som krävs (SELECT, INSERT, UPDATE etc.).
- Håll känslig konfiguration (connection strings, secrets) i säkra lösningar: environment variables, Azure Key Vault, eller liknande.
- Undvik EnableSensitiveDataLogging i produktion då det kan läcka persondata i loggar.
Backup och återställning
- Ha en dokumenterad backup-strategi: full backup, differential, log backup beroende på DB provider.
- Testa återställning regelbundet i staging för att säkerställa att backups är användbara.
Connection pooling och skalning
- ADO.NET providerer hanterar pooling per connection string. Skapa inte onödiga connection strings (olika variabler) som splittrar poolen.
- Håll DbContext kortlivad per operation/requests, inte som singleton.
Testning
- Använd in-memory eller real SQLite i minne för snabb integrationstestning, men var medveten om beteendeskillnader mellan providers (t.ex. SQLite vs SQL Server).
- Mocka repository/UnitOfWork i unit tests och använd riktiga databaser i integrationstester.
- Kör migrations i CI för att verifiera att migrationerna bygger och att Up/Down fungerar.
Vanliga fallgropar
1. Glömma SaveChanges()
var customer = new Customer { Name = "Anna" };
_context.Customers.Add(customer);
// Glömde _context.SaveChanges()!
// Inget sparas i databasen!
2. Tracking issues
// Ladda customer
var customer = _context.Customers.Find(1);
// Senare... försök uppdatera
var updatedCustomer = new Customer { CustomerId = 1, Name = "New Name" };
_context.Customers.Update(updatedCustomer);
_context.SaveChanges();
// FEL! Två instanser av samma entity tracked!
Lösning:
// Använd AsNoTracking för read-only
var customer = _context.Customers
.AsNoTracking()
.Find(1);
// ELLER detach gamla först
_context.Entry(customer).State = EntityState.Detached;
3. Lazy loading utan Include
var customer = _context.Customers.Find(1);
Console.WriteLine(customer.Orders.Count); // NULL! Orders inte laddad!
// Lösning:
var customer = _context.Customers
.Include(c => c.Orders)
.Find(1);
4. Connection string i kod
// DÅLIGT ❌
options.UseSqlite("Data Source=C:\\Users\\Anna\\shop.db");
// BRA ✅
var dbPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"databases",
"shop.db"
);
options.UseSqlite($"Data Source={dbPath}");
Slutsats
Att planera en databas för C# innebär att tänka på mappningen mellan objekt och tabeller från start. De tre huvudvalen är:
Entity Framework Core - Full-featured ORM
- ✅ Använd för: De flesta applikationer
- ✅ Fördelar: Migrations, LINQ, change tracking
- ❌ Nackdelar: Prestanda overhead, learning curve
Dapper - Micro ORM
- ✅ Använd för: Prestanda-kritiska applikationer
- ✅ Fördelar: Snabbt, enkelt, full SQL-kontroll
- ❌ Nackdelar: Ingen change tracking, manuell SQL
ADO.NET - Raw access
- ✅ Använd för: Maximum prestanda, bulk operations
- ✅ Fördelar: Full kontroll, noll overhead
- ❌ Nackdelar: Mycket boilerplate, manuell allt
Rekommendation:
- Börja med EF Core (enkelt, produktivt)
- Optimera med Dapper där behövs (hot paths)
- Använd ADO.NET för bulk operations
Patterns:
- Repository Pattern för testbarhet
- Unit of Work för transaktioner
- DTO för API responses
- Async/await för skalbarhet
TL;DR
C# + Databas:
ORM-val:
- EF Core - Standard val, migrations, LINQ
- Dapper - Snabbt, SQL-kontroll
- ADO.NET - Maximum prestanda
Code First (rekommenderat):
- Skapa C# klasser
- Skapa DbContext
dotnet ef migrations add Initialdotnet ef database update
Navigation properties:
public class Customer {
public List<Order> Orders { get; set; } // 1:N
}
public class Order {
public Customer Customer { get; set; } // N:1
}
Patterns:
- Repository - Abstrahera dataaccess
- Unit of Work - Gruppera transaktioner
- DTO - Separera entities från API
Best practices:
- ✅ Använd async/await
- ✅ Dispose DbContext korrekt
- ✅ Include för eager loading
- ✅ Migrations för schema-ändringar
- ✅ AsNoTracking för read-only
- ❌ INTE connection string i kod
- ❌ INTE glömma SaveChanges()
Mappning:
- C#
int→ SQLINTEGER - C#
string→ SQLTEXT - C#
decimal→ SQLDECIMAL(för pengar!) - C#
DateTime→ SQLTEXT(ISO 8601) - C#
List<T>→ SQL Foreign Key relation
Planera från båda hållen samtidigt! 🎯