Skip to content

Adapter Develop

project-spec -> architecture-design -> domain-develop -> application-develop -> adapter-develop -> observability-develop -> test-develop

  • Reads application/03-implementation-results.md generated by the application-develop skill to confirm the Port list (IRepository, IQueryPort, External Service).
  • If 01-architecture-design.md generated by the architecture-design skill exists, it is read to confirm folder structure and persistence strategy.
  • If prerequisite documents are missing, the user is asked directly.

Once Port interfaces in the Application layer are defined, Adapters that implement them must be written. From InMemory Repository, EF Core Repository, Dapper Query Adapter, FastEndpoints endpoint, to DI registration — each Adapter type has repeating structures (base class inheritance, [GenerateObservablePort], IO.lift/IO.liftAsync wrapping, Mapper, PropertyMap).

The /adapter-develop skill automates this repetition. When you provide Port interfaces and requirements, it generates Adapter implementations, Mappers, and DI registration code matching Functorium framework patterns in 4 steps.

StepTaskDeliverable
1Port -> Adapter mappingDetermine Adapter type per Port interface
2Implementation generationAdapter classes, Model, Mapper, Configuration
3DI registrationRegisterScopedObservablePort registration code
4EF Core setupDbContext, Migration, Query Filter (if applicable)
Adapter TypeBase ClassIO WrappingDescription
InMemory RepositoryInMemoryRepositoryBase<T, TId>IO.liftIn-memory store for testing
EF Core RepositoryEfCoreRepositoryBase<T, TId, TModel>IO.liftAsyncPersistent store
Dapper QueryDapperQueryBase<T, TDto>CQRS Read Side
FastEndpointsEndpoint<TReq, TRes>HTTP endpoint
External APIDirect implementationIO.liftAsyncExternal HTTP API integration
PatternUsage
Observable PortApply [GenerateObservablePort] attribute
Synchronous wrappingIO.lift(() => Fin.Succ(value))
Asynchronous wrappingIO.liftAsync(async () => { ... return Fin.Succ(result); })
Adapter errorAdapterError.For<TAdapter>(new NotFound(), id, message)
DI registrationservices.RegisterScopedObservablePort<IPort, AdapterObservable>()
Mapperinternal static class extension methods, ToModel()/ToDomain()

Adapter projects follow the 3-dimensional folder structure.

{Name}.Adapters.Persistence/
└── {Aggregate}/ # Primary: Aggregate (what)
├── {Aggregate}.Model.cs # DB POCO
├── {Aggregate}.Configuration.cs # EF Core Fluent API
├── {Aggregate}.Mapper.cs # Domain <-> Model conversion
├── Repositories/ # Secondary: CQRS Role (write)
│ ├── {Aggregate}RepositoryEfCore.cs # Tertiary: Technology (how)
│ └── {Aggregate}RepositoryInMemory.cs
└── Queries/ # Secondary: CQRS Role (read)
├── {Aggregate}QueryDapper.cs
└── {Aggregate}QueryInMemory.cs

Naming Pattern: {Subject}{Role}{Variant} (e.g., ProductRepositoryEfCore, ProductQueryDapper)

ObservableSignal — Adapter Internal Operational Logging

Section titled “ObservableSignal — Adapter Internal Operational Logging”

Separately from the automatic observability of Observable Port, ObservableSignal is used when developers need to directly write operational logs inside an Adapter.

public override FinT<IO, Product> GetById(ProductId id)
{
return IO.liftAsync(async () =>
{
var model = await ReadQuery().FirstOrDefaultAsync(ByIdPredicate(id));
if (model is null)
{
ObservableSignal.Info("cache_miss", new { ProductId = id.ToString() });
return NotFoundError(id);
}
ObservableSignal.Info("cache_hit", new { ProductId = id.ToString() });
return Fin.Succ(ToDomain(model));
});
}

ObservableSignal supplementary fields use the adapter.* prefix. These fields are propagated only to the Logging Pillar.

/adapter-develop Create a product InMemory Repository.

Invoking /adapter-develop without arguments starts the skill in interactive mode, collecting requirements through conversation.

  1. Adapter analysis — Shows the Adapter type and implementation plan per Port in a table
  2. User confirmation — Proceed to code generation after confirming the analysis results
  3. Code generation — Generates Adapter, Model, Mapper, DI registration code
  4. Build verification — Runs dotnet build to confirm passing

Example 1: Beginner — InMemory Repository

Section titled “Example 1: Beginner — InMemory Repository”

The most basic Adapter pattern. Implements a test in-memory store with InMemoryRepositoryBase inheritance, [GenerateObservablePort] application, and IO.lift wrapping.

/adapter-develop Create a product InMemory Repository.
DeliverableTypeDescription
RepositoryInMemoryProductRepositoryInherits InMemoryRepositoryBase<Product, ProductId>
DI RegistrationRegisterScopedObservablePortRegistered as Observable version

InMemory RepositoryInMemoryRepositoryBase inheritance, [GenerateObservablePort] applied:

[GenerateObservablePort]
public class InMemoryProductRepository
: InMemoryRepositoryBase<Product, ProductId>, IProductRepository
{
internal static readonly ConcurrentDictionary<ProductId, Product> Products = new();
protected override ConcurrentDictionary<ProductId, Product> Store => Products;
public InMemoryProductRepository(IDomainEventCollector eventCollector)
: base(eventCollector) { }
public virtual FinT<IO, bool> Exists(Specification<Product> spec)
{
return IO.lift(() =>
{
bool exists = Products.Values.Any(p => spec.IsSatisfiedBy(p));
return Fin.Succ(exists);
});
}
}

DI Registration — Uses the Observable version generated by Source Generator:

services.RegisterScopedObservablePort<IProductRepository, InMemoryProductRepositoryObservable>();

Example 2: Intermediate — EF Core Repository + Configuration + Mapper

Section titled “Example 2: Intermediate — EF Core Repository + Configuration + Mapper”

Adds a persistence layer to Example 1. Shows the EF Core Repository’s 3-argument constructor pattern (EventCollector, ApplyIncludes, PropertyMap), Persistence Model (POCO), Mapper extension methods, and EF Core Configuration.

/adapter-develop Implement a product EF Core Repository. Include Soft Delete, using SQLite.
DeliverableTypeDescription
RepositoryEfCoreProductRepositoryEfCoreRepositoryBase inheritance, Soft Delete override
ModelProductModelPOCO, primitive types only
ConfigurationProductConfigurationEF Core Fluent API, Query Filter
MapperProductMapperinternal static class, ToModel()/ToDomain()
DI RegistrationRegisterScopedObservablePortRegistered as Observable version

EF Core Repository — 3-argument constructor, PropertyMap, Soft Delete override:

[GenerateObservablePort]
public class EfCoreProductRepository
: EfCoreRepositoryBase<Product, ProductId, ProductModel>, IProductRepository
{
private readonly LayeredArchDbContext _dbContext;
public EfCoreProductRepository(LayeredArchDbContext dbContext, IDomainEventCollector eventCollector)
: base(eventCollector,
q => q.Include(p => p.ProductTags),
new PropertyMap<Product, ProductModel>()
.Map(p => (decimal)p.Price, m => m.Price)
.Map(p => (string)p.Name, m => m.Name)
.Map(p => p.Id.ToString(), m => m.Id))
=> _dbContext = dbContext;
protected override DbContext DbContext => _dbContext;
protected override DbSet<ProductModel> DbSet => _dbContext.Products;
protected override Product ToDomain(ProductModel model) => model.ToDomain();
protected override ProductModel ToModel(Product p) => p.ToModel();
// Soft Delete override
public override FinT<IO, int> Delete(ProductId id)
{
return IO.liftAsync(async () =>
{
var model = await ReadQueryIgnoringFilters()
.FirstOrDefaultAsync(ByIdPredicate(id));
if (model is null)
return NotFoundError(id);
var product = ToDomain(model);
product.Delete("system");
var updatedModel = ToModel(product);
DbSet.Attach(updatedModel);
_dbContext.Entry(updatedModel).Property(p => p.DeletedAt).IsModified = true;
_dbContext.Entry(updatedModel).Property(p => p.DeletedBy).IsModified = true;
EventCollector.Track(product);
return Fin.Succ(1);
});
}
}

Mapperinternal static class, restoring domain without validation via CreateFromValidated:

internal static class ProductMapper
{
public static ProductModel ToModel(this Product product) => new()
{
Id = product.Id.ToString(),
Name = product.Name,
Price = product.Price,
CreatedAt = product.CreatedAt,
DeletedAt = product.DeletedAt.ToNullable(),
DeletedBy = product.DeletedBy.Match(Some: v => (string?)v, None: () => null),
};
public static Product ToDomain(this ProductModel model) =>
Product.CreateFromValidated(
ProductId.Create(model.Id),
ProductName.CreateFromValidated(model.Name),
Money.CreateFromValidated(model.Price),
model.CreatedAt,
Optional(model.DeletedAt),
Optional(model.DeletedBy));
}

Example 3: Advanced — Dapper Query + Specification + Endpoint

Section titled “Example 3: Advanced — Dapper Query + Specification + Endpoint”

Adds a CQRS Read Side to Example 2. Shows a Dapper-based Query Adapter, DapperSpecTranslator for converting Specifications to SQL WHERE clauses, FastEndpoints endpoint, and DI registration.

/adapter-develop Implement a product search API. Dapper Query + Specification-based search + pagination + FastEndpoints.
DeliverableTypeDescription
Query AdapterDapperProductQueryInherits DapperQueryBase<Product, ProductSummaryDto>
Spec TranslatorProductSpecTranslatorSpecification -> SQL WHERE clause conversion
EndpointSearchProductsEndpointFastEndpoints, pagination/sort support
DI RegistrationRegisterScopedObservablePortQuery Adapter + IDbConnection registration

Dapper Query AdapterDapperQueryBase inheritance, direct SQL writing:

[GenerateObservablePort]
public class DapperProductQuery
: DapperQueryBase<Product, ProductSummaryDto>, IProductQuery
{
public string RequestCategory => "QueryAdapter";
protected override string SelectSql => "SELECT Id AS ProductId, Name, Price FROM Products";
protected override string CountSql => "SELECT COUNT(*) FROM Products";
protected override string DefaultOrderBy => "Name ASC";
protected override Dictionary<string, string> AllowedSortColumns { get; } =
new(StringComparer.OrdinalIgnoreCase) { ["Name"] = "Name", ["Price"] = "Price" };
public DapperProductQuery(IDbConnection connection)
: base(connection, ProductSpecTranslator.Instance) { }
}

DapperSpecTranslator — Converts Specification to SQL WHERE clause:

internal static class ProductSpecTranslator
{
internal static readonly DapperSpecTranslator<Product> Instance = new DapperSpecTranslator<Product>()
.WhenAll(alias =>
{
var p = DapperSpecTranslator<Product>.Prefix(alias);
return ($"WHERE {p}DeletedAt IS NULL", new DynamicParameters());
})
.When<ProductPriceRangeSpec>((spec, alias) =>
{
var p = DapperSpecTranslator<Product>.Prefix(alias);
return ($"WHERE {p}DeletedAt IS NULL AND {p}Price >= @MinPrice AND {p}Price <= @MaxPrice",
DapperSpecTranslator<Product>.Params(
("MinPrice", (decimal)spec.MinPrice),
("MaxPrice", (decimal)spec.MaxPrice)));
});
}

FastEndpoints Endpoint — HTTP response conversion with SendFinResponseAsync:

public sealed class SearchProductsEndpoint
: Endpoint<SearchProductsEndpoint.Request, SearchProductsEndpoint.Response>
{
private readonly IMediator _mediator;
public SearchProductsEndpoint(IMediator mediator) => _mediator = mediator;
public override void Configure()
{
Get("api/products/search");
AllowAnonymous();
}
public override async Task HandleAsync(Request req, CancellationToken ct)
{
var usecaseRequest = new SearchProductsQuery.Request(
req.Name ?? "", req.MinPrice ?? 0, req.MaxPrice ?? 0,
req.Page ?? 1, req.PageSize ?? PageRequest.DefaultPageSize,
req.SortBy ?? "", req.SortDirection ?? "");
var result = await _mediator.Send(usecaseRequest, ct);
var mapped = result.Map(r => new Response(r.Products.ToList(), r.TotalCount, r.Page, r.PageSize));
await this.SendFinResponseAsync(mapped, ct);
}
public sealed record Request(
[property: QueryParam] string? Name = null,
[property: QueryParam] decimal? MinPrice = null,
[property: QueryParam] decimal? MaxPrice = null,
[property: QueryParam] int? Page = null,
[property: QueryParam] int? PageSize = null,
[property: QueryParam] string? SortBy = null,
[property: QueryParam] string? SortDirection = null);
public new sealed record Response(
List<ProductSummaryDto> Products, int TotalCount, int Page, int PageSize);
}

DI Registration — Per-Provider branching, using Observable versions:

// InMemory
services.RegisterScopedObservablePort<IProductRepository, InMemoryProductRepositoryObservable>();
services.RegisterScopedObservablePort<IProductQuery, InMemoryProductQueryObservable>();
// SQLite (EF Core + Dapper)
services.RegisterScopedObservablePort<IProductRepository, EfCoreProductRepositoryObservable>();
services.AddScoped<IDbConnection>(_ =>
{
var conn = new SqliteConnection(connectionString);
conn.Open();
return conn;
});
services.RegisterScopedObservablePort<IProductQuery, DapperProductQueryObservable>();