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# ä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# typSQLite typSQL Server typNotering
intINTEGERINT32-bit heltal
longINTEGERBIGINT64-bit heltal
stringTEXTNVARCHAR(MAX)UTF-16 i C#, varies i DB
boolINTEGERBITSQLite: 0/1
DateTimeTEXTDATETIME2ISO 8601 i SQLite
decimalREALDECIMAL(18,2)För pengar!
byte[]BLOBVARBINARY(MAX)Binär data
GuidTEXTUNIQUEIDENTIFIERUUID

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 migrationMå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-kodMåste hantera SqlDataReader manuelltRisk 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:

  1. Börja med EF Core (enkelt, produktivt)
  2. Optimera med Dapper där behövs (hot paths)
  3. 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):

  1. Skapa C# klasser
  2. Skapa DbContext
  3. dotnet ef migrations add Initial
  4. dotnet 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 → SQL INTEGER
  • C# string → SQL TEXT
  • C# decimal → SQL DECIMAL (för pengar!)
  • C# DateTime → SQL TEXT (ISO 8601)
  • C# List<T> → SQL Foreign Key relation

Planera från båda hållen samtidigt! 🎯


Upp

Upp


Licens: Apache 2.0 | © 2023 Marcus Medina, Campus Mölndal. Alla rättigheter förbehållna.
Du får använda och modifiera detta verk enligt villkoren i Apache License, Version 2.0. Du får inte använda detta verk för kommersiella ändamål utan tillstånd från upphovsmannen.