3 · Wire EF Core
เนื้อหานี้ยังไม่ได้แปลเป็นภาษาไทย แสดงเป็นภาษาอังกฤษแทน
The in-memory catalog from step 2 gave us a working REST surface. Now we replace it with EF Core through Cephalon.Data.EntityFramework, which gives us read/write DbContext baselines, the inbox/outbox storage, and Sfid.EntityFramework integration.
3.1 Start Postgres locally
Section titled “3.1 Start Postgres locally”docker run -d --name acme-postgres ` -e POSTGRES_USER=postgres ` -e POSTGRES_PASSWORD=postgres ` -e POSTGRES_DB=acmestore ` -p 5432:5432 ` postgres:16-alpineThe connection string for the app will be:
Host=localhost;Port=5432;Database=acmestore;Username=postgres;Password=postgresIn production you would store this in a secrets store and inject it via environment variables. We’ll wire that in step 8.
3.2 Add the data packages
Section titled “3.2 Add the data packages”Open Directory.Packages.props and add:
<PackageVersion Include="Cephalon.Data" Version="0.1.0-preview" /><PackageVersion Include="Cephalon.Data.EntityFramework" Version="0.1.0-preview" /><PackageVersion Include="Cephalon.Ids.Sfid" Version="0.1.0-preview" /><PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />Update src/Acme.Store.Modules.Products/Acme.Store.Modules.Products.csproj:
<ItemGroup> <PackageReference Include="Cephalon.Abstractions" /> <PackageReference Include="Cephalon.AspNetCore" /> <PackageReference Include="Cephalon.Data" /> <PackageReference Include="Cephalon.Data.EntityFramework" /> <PackageReference Include="Cephalon.Ids.Sfid" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /></ItemGroup>Restore packages:
dotnet restore3.3 Build the DbContext
Section titled “3.3 Build the DbContext”Replace src/Acme.Store.Modules.Products/Domain/Product.cs with the EF entity:
using Cephalon.Ids.Sfid;
namespace Acme.Store.Modules.Products.Domain;
public sealed class Product{ public Sfid Id { get; init; } = Sfid.NewSfid(); public required string Name { get; set; } public required string Sku { get; set; } public required decimal Price { get; set; } public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;}
Sfidis a sortable, k-ordered identifier fromSfid.Net. Generated apps default toSfidfor theIdStrategysetting — see Technology → Identifiers.
Create src/Acme.Store.Modules.Products/Data/ProductsDbContext.cs:
using Acme.Store.Modules.Products.Domain;using Cephalon.Data.EntityFramework;using Microsoft.EntityFrameworkCore;
namespace Acme.Store.Modules.Products.Data;
public sealed class ProductsDbContext : CephalonDbContext{ public ProductsDbContext(DbContextOptions<ProductsDbContext> options) : base(options) { }
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder);
builder.Entity<Product>(entity => { entity.ToTable("products"); entity.HasKey(p => p.Id); entity.Property(p => p.Name).HasMaxLength(200).IsRequired(); entity.Property(p => p.Sku).HasMaxLength(64).IsRequired(); entity.Property(p => p.Price).HasColumnType("numeric(12,2)"); entity.HasIndex(p => p.Sku).IsUnique(); }); }}
CephalonDbContextis the recommended base. It wires theSfid.EntityFrameworkvalue converter, sets up the inbox/outbox storage when eventing is enabled, and registers conventions that play nicely with the data abstractions.
3.4 Replace the in-memory catalog
Section titled “3.4 Replace the in-memory catalog”Update src/Acme.Store.Modules.Products/Domain/IProductCatalog.cs:
namespace Acme.Store.Modules.Products.Domain;
public interface IProductCatalog{ Task<IReadOnlyList<Product>> ListAsync(CancellationToken ct = default); Task<Product?> FindAsync(Sfid id, CancellationToken ct = default); Task<Product> CreateAsync(Product product, CancellationToken ct = default);}Create src/Acme.Store.Modules.Products/Data/EfProductCatalog.cs:
using Acme.Store.Modules.Products.Domain;using Cephalon.Ids.Sfid;using Microsoft.EntityFrameworkCore;
namespace Acme.Store.Modules.Products.Data;
public sealed class EfProductCatalog(ProductsDbContext db) : IProductCatalog{ public async Task<IReadOnlyList<Product>> ListAsync(CancellationToken ct = default) => await db.Products.OrderBy(p => p.CreatedAt).ToListAsync(ct);
public Task<Product?> FindAsync(Sfid id, CancellationToken ct = default) => db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
public async Task<Product> CreateAsync(Product product, CancellationToken ct = default) { db.Products.Add(product); await db.SaveChangesAsync(ct); return product; }}Delete src/Acme.Store.Modules.Products/Domain/InMemoryProductCatalog.cs.
3.5 Wire the module
Section titled “3.5 Wire the module”Replace the body of ProductsModule.cs:
using Acme.Store.Modules.Products.Data;using Acme.Store.Modules.Products.Domain;using Cephalon.Abstractions.Modules;using Cephalon.AspNetCore.Behaviors;using Cephalon.Data.EntityFramework;using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.Configuration;using Microsoft.Extensions.DependencyInjection;
namespace Acme.Store.Modules.Products;
public sealed class ProductsModule : RestBehaviorModuleBase{ public override ModuleDescriptor Describe() => new( name: "Acme.Store.Modules.Products", version: "1.0.0", capabilities: [Capability.Data, Capability.Audit]);
public override void RegisterServices(IServiceCollection services) { services.AddCephalonEntityFramework<ProductsDbContext>((sp, options) => { var config = sp.GetRequiredService<IConfiguration>(); var conn = config.GetConnectionString("Products") ?? throw new InvalidOperationException("Missing 'Products' connection string."); options.UseNpgsql(conn); });
services.AddScoped<IProductCatalog, EfProductCatalog>(); }
protected override void ConfigureRestBehaviors(IRestBehaviorBuilder builder) { builder.MapProfile<ListProductsBehavior>(); builder.MapProfile<GetProductBehavior>(); builder.MapProfile<CreateProductBehavior>(); }}3.6 Add the create behavior
Section titled “3.6 Add the create behavior”Create src/Acme.Store.Modules.Products/Behaviors/CreateProductBehavior.cs:
using Acme.Store.Modules.Products.Domain;using Cephalon.AspNetCore.Behaviors;using Microsoft.AspNetCore.Http;
namespace Acme.Store.Modules.Products.Behaviors;
public sealed record CreateProductPayload(string Name, string Sku, decimal Price);
public sealed class CreateProductBehavior : IRestBehavior{ public RestRoute Route => RestRoute.Post("/products");
public async Task<IResult> Handle(CreateProductPayload payload, IProductCatalog catalog, CancellationToken ct) { if (payload.Price < 0) return Results.BadRequest(new { error = "price must be non-negative" }); if (string.IsNullOrWhiteSpace(payload.Sku)) return Results.BadRequest(new { error = "sku is required" });
var product = new Product { Name = payload.Name, Sku = payload.Sku, Price = payload.Price }; var saved = await catalog.CreateAsync(product, ct); return Results.Created($"/products/{saved.Id}", saved); }}3.7 Update connection strings
Section titled “3.7 Update connection strings”Append to src/Acme.Store.Host/appsettings.Development.json:
{ "ConnectionStrings": { "Products": "Host=localhost;Port=5432;Database=acmestore;Username=postgres;Password=postgres" }}For production, the same key reads from environment variables: ConnectionStrings__Products=....
3.8 Create the initial migration
Section titled “3.8 Create the initial migration”dotnet tool install -g dotnet-ef --version 10.0.0cd src/Acme.Store.Modules.Productsdotnet ef migrations add Initial --context ProductsDbContext --output-dir Data/Migrationscd ../..Run the migration on startup. Open src/Acme.Store.Host/Program.cs and add the migrator block:
using Acme.Store.Modules.Products.Data;using Cephalon.AspNetCore;using Cephalon.Engine;using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Services .AddCephalonAspNetCore() .AddModulesFromAssemblies( typeof(Program).Assembly, typeof(Acme.Store.Modules.Products.ProductsModule).Assembly) .Build(builder);
// Apply migrations in Development. Production should use a separate migrator job.if (app.Environment.IsDevelopment()){ using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<ProductsDbContext>(); await db.Database.MigrateAsync();}
app.MapCephalon();app.MapHealthChecks("/health");
app.Run();3.9 Run and verify
Section titled “3.9 Run and verify”dotnet run --project ./src/Acme.Store.HostIn another shell:
# Createcurl -X POST http://localhost:5000/products ` -H "Content-Type: application/json" ` -d '{"name":"USB-C Hub","sku":"HB-USBC","price":59.00}'
# Listcurl http://localhost:5000/productsYou should see the row persist between runs.
What you should have now
Section titled “What you should have now”- EF Core wired through
Cephalon.Data.EntityFramework. - A
ProductsDbContextwith conventions inherited fromCephalonDbContext. - CRUD endpoints persisting to Postgres.
- An initial migration committed under
Data/Migrations/.
Pitfalls we hit the first time
Section titled “Pitfalls we hit the first time”- Running migrations on every restart in production. Don’t. Use a dedicated migrator step in CI/CD or a sidecar job in your deploy script.
- Connection strings via
appsettings.json. Fine forDevelopment. ForProduction, useEnvironment=ConnectionStrings__Products=...or a managed secret. - Mixing
SfidandGuidids in the same DbContext. Stay consistent — theIdStrategysetting inappsettings.jsonis meant to be a per-app decision.