Business logic as small use cases: a C# framework for developers and their coding agents.
One operation is one class with one Execute. Whether you change an operation or Claude Code, Codex, Cursor
or Copilot does, the diff is usually that class and its test, small enough to review. mcs init writes the conventions into AGENTS.md
for the agent, and about twenty Roslyn rules make them build errors. Every C# service at
MagicDoor, Revoco and AgentParley
runs on MagicCSharp, an MIT-licensed framework designed by engineers from Amazon and Google.
Quick start · Working with coding agents · Example service · Packages · Layout guide · Changelog
| Guides for the agent | mcs init writes AGENTS.md for Codex, Cursor, Copilot and most agents, CLAUDE.md for Claude Code (it imports AGENTS.md), and one guide per topic in .ai-knowledge/. |
| One operation, one class | A business operation is a class with one Execute, and AddMagicCSharp() finds and registers every one. Bigger work is those classes called in order. |
| House style as build errors | MagicCSharp.Analyzers ships about twenty Roslyn rules. DateTime.Now, a positional record, a missing brace or a dependency called useCase fails the build. |
| Tests that move time | .NET's TimeProvider throughout, background services included. Tests run the real use cases against a real PostgreSQL, with a FakeTimeProvider the test moves. |
| Events on any transport | Publish through one IEventDispatcher; handlers are discovered. In-process, Kafka or SQS is one registration, and publishers and handlers are the same on all three. |
| Drift-free background services | Interval or time-of-day schedules from the clock, so an hourly job stays on the hour. One instance runs each occurrence under a lock named for the service, file-based unless you register a shared provider. |
| Repositories with filters | Entity, edit and filter records; one Get(filter) per entity; pagination, soft delete and search as opt-ins, on EF Core and PostgreSQL. Snowflake ids are assigned before the insert. |
| Errors that mean something | The domain throws not-found, conflict, invalid-operation or validation; the web layer answers 404, 409, 422 or 400 as problem+json with the request id. A 500's detail is logged and hidden outside Development. |
| Structure from a tool | mcs creates services, domains and entities the same way every time, and re-running any command changes nothing. mcs validate checks the handful of conventions the compiler cannot. |
| Domain services, one repository | Each app under Apps/ is its own deployable service for one business area, split inside into domains. Apps share Libs/ and talk through events. Make them smaller and you have microservices. |
Needs the .NET 10 SDK, PostgreSQL for a service with a database, and Docker for the repository tests. The
generated settings expect PostgreSQL on localhost as postgres/postgres.
dotnet tool install -g MagicCSharp.Cli
mcs init --prefix Acme # build rules, AGENTS.md, CLAUDE.md, .ai-knowledge/
mcs create-app --name Leasing --database leasing # a service
mcs create-domain --solution Leasing --name Leases --models --tests
mcs add-entity --solution Leasing --domain Leases --name Lease --paginated
dotnet run --project Apps/Leasing/Leasing.Appadd-entity wrote the entity, edit and filter records, the DAL, the repository interface and its EF
implementation, the DbSet and the registration. mcs init wrote the build rules and the conventions for
the agent:
AGENTS.md the guide Codex, Cursor, Copilot and most agents read
CLAUDE.md @AGENTS.md, so Claude Code reads the same text
.ai-knowledge/
INDEX.md which guide to read for which task
architecture-and-project-structure.md where code goes
use-case-patterns.md
entities-and-database.md
events-and-messaging.md
background-services.md
api-and-controller-conventions.md
time-and-dates.md
testing-conventions.md
coding-style.md
project-tooling.md
project.md what is specific to this repository; yours, and mcs leaves it alone
Now open the repository in Claude Code, Codex, Cursor or Copilot. The agent reads AGENTS.md at the root.
It says that business logic goes in use cases, that domain code depends on ports, and that mcs creates
projects and entities. A change is done when dotnet build, dotnet test and mcs validate pass.
Or add the packages to an app you already have. There are fourteen, under one version number, and each
stands alone; the layout and mcs are optional.
dotnet add package MagicCSharp.Appvar builder = WebApplication.CreateBuilder(args);
builder.AddMagicApp();
var app = builder.Build();
app.UseMagicApp(builder);
app.Run();Those two calls register use cases, TimeProvider, Snowflake ids, request ids, in-process events,
scheduling defaults and problem-details errors. At startup they resolve every registration once, so a
miswired dependency fails the deploy. What each option does →
The example is a whole leasing service built from the published packages. It signs a lease as a chain of use cases, applies late fees from an hourly background service and sends notifications over Kafka. Its tests move the clock, and its CI builds it from nuget.org every week.
A request record, an interface and a class. The interface extends IMagicUseCase, and that is the only
framework type in it. This is CreateLeaseUseCase from the example, without its logging and argument checks:
public record CreateLeaseRequest
{
public required long PropertyId { get; init; }
public required string TenantName { get; init; }
public required string TenantEmail { get; init; }
public required decimal MonthlyRent { get; init; }
public required decimal SecurityDeposit { get; init; }
public required DateOnly StartDate { get; init; }
public required DateOnly EndDate { get; init; }
}
public interface ICreateLeaseUseCase : IMagicUseCase
{
Task<Lease> Execute(CreateLeaseRequest request);
}
public class CreateLeaseUseCase(ILeasesRepository leasesRepository) : ICreateLeaseUseCase
{
public async Task<Lease> Execute(CreateLeaseRequest request)
{
if (request.EndDate <= request.StartDate)
{
throw new ValidationException("A lease must end after it starts.");
}
return await leasesRepository.Create(new LeaseEdit
{
PropertyId = request.PropertyId,
TenantName = request.TenantName,
TenantEmail = request.TenantEmail,
MonthlyRent = request.MonthlyRent,
SecurityDeposit = request.SecurityDeposit,
StartDate = request.StartDate,
EndDate = request.EndDate,
});
}
}builder.Services.AddMagicCSharp() finds every IMagicUseCase and registers it under its interface, so a
controller takes ICreateLeaseUseCase and nothing is wired by hand. A test constructs the class directly;
FakeLeasesRepository is a class in the test project that implements ILeasesRepository:
var createLease = new CreateLeaseUseCase(new FakeLeasesRepository());
var creating = createLease.Execute(new CreateLeaseRequest
{
PropertyId = 1,
TenantName = "Dana Whitfield",
TenantEmail = "dana@example.com",
MonthlyRent = 1850m,
SecurityDeposit = 1850m,
StartDate = new DateOnly(2027, 3, 1),
EndDate = new DateOnly(2026, 3, 1),
});
await Assert.ThrowsAsync<ValidationException>(() => creating);Signing a lease is three operations called in order, then an event. Each operation exists on its own. This
is SignLeaseUseCase from the example, without its logging and the deposit charge:
public class SignLeaseUseCase(
IGetPropertiesUseCase getProperties,
ICreateLeaseUseCase createLease,
ICreateChargesUseCase createCharges,
IEventDispatcher eventDispatcher) : ISignLeaseUseCase
{
public async Task<SignLeaseResult> Execute(SignLeaseRequest request)
{
var property = await getProperties.Execute(request.PropertyId);
NotFoundException.ThrowIfNull(property, request.PropertyId);
var lease = await createLease.Execute(new CreateLeaseRequest
{
PropertyId = property.Id,
TenantName = request.TenantName,
TenantEmail = request.TenantEmail,
MonthlyRent = request.MonthlyRent,
SecurityDeposit = request.SecurityDeposit,
StartDate = request.StartDate,
EndDate = request.EndDate,
});
var charges = await createCharges.Execute(
[
new ChargeEdit
{
LeaseId = lease.Id,
Type = ChargeType.Rent,
Amount = lease.MonthlyRent,
DueDate = lease.StartDate,
},
]);
eventDispatcher.Dispatch(new LeaseSignedEvent
{
LeaseId = lease.Id,
PropertyId = property.Id,
});
return new SignLeaseResult
{
Lease = lease,
Charges = charges,
};
}
}The steps that must happen are in the chain: getProperties, createLease, createCharges. The welcome
email is a handler on LeaseSignedEvent, because a lease is signed whether or not the email goes. A second
reaction to signing is another handler class, and this class stays as it is.
A chain is not a transaction. If createCharges throws, the lease that createLease wrote is already
there, and the caller gets the exception and decides. Two steps that must succeed together go inside one
use case.
Ask an agent to change late fees in a service built around a LeaseService, and it edits a class that many
other things call. The reviewer reads all of it. Ask the same thing here and the unit of work is
ApplyLateFeesUseCase: one file, one Execute, one test class. The agent can still be wrong. What changes
is where a mistake can land and how it is found:
-
The scope is a class. What an operation can touch is listed in its constructor. The agent reads the use case, the interfaces it takes and its test class.
-
Its callers are one search away. They are the classes that take
IApplyLateFeesUseCase. The diff is the class and its test, small enough for a person to read all of it. -
The check is real. One
dotnet testruns the real use cases, repositories and handlers against PostgreSQL in a throwaway container, with aFakeTimeProviderthe test moves. "Three days later the fee is still charged once" advances the clock three days and runs the use case again. -
The conventions are compile errors.
MagicCSharp.Analyzersturns the house style into build errors, and the message says what to do. This repository builds under the same rules.error MCS0008: Replace 'DateTime.UtcNow' with an injected TimeProvider and call 'timeProvider.GetUtcNow()' -
The structure comes from
mcs.AGENTS.mdtells the agent to usemcsfor every project, entity and repository, so it adds an entity the way everyone else does. -
The rules are written down for it.
AGENTS.mdis about fifty lines and sends the agent to.ai-knowledge/INDEX.md, which names the guide each task needs. It also tells the agent to ask before widening the scope, to commit only when asked, and to say which checks it ran.mcs update ai-filesrefreshes the shipped guides to the installedmcsversion and leavesproject.mdalone. Their source is AIAgents/.
The same things make a person's pull request reviewable.
| You want to | Start here |
|---|---|
| See a whole service | The property-management example |
| Give an AI agent the conventions | AIAgents/, the source of AGENTS.md, CLAUDE.md and .ai-knowledge/ |
| Write use cases and wire a host | MagicCSharp · MagicCSharp.App |
| Store entities | MagicCSharp.Data · .Data.EntityFramework · .Data.Postgres |
| Publish and handle events | MagicCSharp.Events · .Events.Kafka · .Events.SQS |
| Run work on a schedule | MagicCSharp.Scheduling |
| Test with fakes, time and a real database | MagicCSharp.Testing · .Testing.Database |
| Enforce the house style | MagicCSharp.Analyzers |
| Scaffold and lay out a repository | mcs · The layout guide · Template overrides |
| Deploy only the apps a change touched | The CI/CD guide · mcs affected |
| Upgrade | CHANGELOG, with an old → new table for every breaking release |
The rest of this page goes deeper, one part at a time.
A use case is one business operation, as a plain class. No base class, no framework types in the signature, and nothing to register by hand. The interface is the point: it is what a controller depends on, what a test replaces, and what stops the next caller reaching past the operation into the repository underneath.
Two implementations of one interface is a startup error naming both, rather than whichever reflection
returned first. An interface extending the marker with no implementation is also a startup error, rather
than a resolution failure on the first request that needs it. [MagicUseCase(ServiceLifetime.Singleton)]
changes the lifetime when the default scoped is wrong.
A use case that needs another takes its interface, exactly as SignLeaseUseCase does above. Lazy<IXxxUseCase>
is registered alongside every interface, for the rare pair that call each other conditionally.
The dependencies are interfaces, so a test constructs the thing directly — the new CreateLeaseUseCase(...)
above is the whole setup. The fakes are yours, in the test project, and there is no framework double to
learn.
MagicCSharp.Testing supplies doubles for the framework's own seams — most usefully FakeTimeProvider, .NET's
own test clock, which you move by hand, so anything time-dependent is testable in milliseconds rather than by
waiting:
timeProvider.SetUtcNow(new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero));
await createLease.Execute(request);
timeProvider.Advance(TimeSpan.FromDays(31));
await applyLateFees.Execute(); // now asserts against a month laterCode that reads DateTime.Now cannot do this, which is why the analyzers make it a build error.
An entity is three records that agree: what you can write, what comes back, and what you can query by.
public record Order : OrderEdit, IIdEntity
{
public required long Id { get; init; }
public required DateTimeOffset Created { get; init; }
public required DateTimeOffset Updated { get; init; }
}
public record OrderEdit // Order derives from it, so an entity
{ // is accepted wherever an edit is
public required long CustomerId { get; init; }
public required decimal Total { get; init; }
public required OrderStatus Status { get; init; }
}
public class OrderFilter // a null property means "do not narrow on this"
{
public long? CustomerId { get; init; }
public OrderStatus? Status { get; init; }
public ComparableRange<DateTimeOffset>? Created { get; init; }
}The interface your domain depends on says what the entity supports. Pagination, soft delete and a search column are separate opt-ins, so a caller can see from the interface which of them exist:
public interface IOrdersRepository :
IRepository<Order, long, OrderEdit, OrderFilter>,
IPaginatedRepository<Order, OrderFilter>;That is everything the domain sees. The implementation lives behind it and inherits all of it except the two things only you know — how to build a row, and what the filter means:
public class OrdersEfRepository(
IDbContextFactory<MagicShopContext> contextFactory,
IKeyGenService keyGenService,
TimeProvider timeProvider,
ILoggerFactory loggerFactory)
: BaseIdPaginatedRepository<MagicShopContext, OrderDal, Order, OrderFilter, OrderEdit>(
contextFactory, timeProvider, loggerFactory),
IOrdersRepository
{
protected override OrderDal CreateDal(OrderEdit edit)
{
return OrderDal.From(edit, keyGenService.GetId());
}
protected override IQueryable<OrderDal> ApplyFilter(IQueryable<OrderDal> query, OrderFilter filter)
{
query = query.ApplyNullableValueFilter(filter.CustomerId, x => (long?)x.CustomerId);
query = query.ApplyNullableValueFilter(filter.Status, x => (OrderStatus?)x.Status);
query = query.ApplyComparableRangeFilter(filter.Created, x => (DateTimeOffset?)x.Created);
return query;
}
}You get create, read by key or filter, four flavours of update, delete, counts and pages — each opening its
own context, each translating to SQL. GetOrThrow raises the domain's NotFoundException so an endpoint
that fetches by id needs no null branch.
OrderDal is the row: the [Table] and [Column] attributes, the EF types, the mapping to and from
Order. It lives in the data project, and Order does not know it exists — the arrow points from storage
toward the domain and never back. The contracts package has no Entity Framework dependency at all, so a
domain project referencing it gets the interfaces and no persistence library.
The paved path is EF + Postgres. The port is not. DynamoDB for writes and Elasticsearch for queries can sit
behind the same IOrdersRepository; the filter is where you admit what that store can actually query, and
adding a property to it is the moment to know which adapter you are stretching.
Timestamps are written UTC. Enums are stored by name, so inserting a member into the middle of one does not change what existing rows mean.
An event is a record carrying ids and primitives — never an entity, because it will be deserialized by code built from a different commit than the one that published it:
public record OrderPlacedEvent : MagicEvent
{
public required long OrderId { get; init; }
public required long CustomerId { get; init; }
}Anything that depends on IEventDispatcher can publish one, and handlers are discovered the same way use
cases are — implement the interface and it runs:
public class SendConfirmationHandler(IEmailService emailService) : IEventHandler<OrderPlacedEvent>
{
public static MagicEventPriority Priority => MagicEventPriority.NotifyUser;
public async Task Handle(OrderPlacedEvent orderPlacedEvent)
{
await emailService.ConfirmOrder(orderPlacedEvent.OrderId);
}
}
public class UpdateSearchIndexHandler(ISearchIndex searchIndex) : IEventHandler<OrderPlacedEvent>
{
public async Task Handle(OrderPlacedEvent orderPlacedEvent)
{
await searchIndex.IndexOrder(orderPlacedEvent.OrderId);
}
}Several handlers for one event run independently, ordered by Priority — which is static, because the
registration reads it without constructing the handler. Adding a capability is adding a class; nothing that
publishes the event changes.
The transport is one registration, and it is the only line that differs between running locally and running on a queue:
services.AddLocalMagicEvents(); // in-process
services.AddMagicKafkaEvents(kafkaConfig); // Kafka
services.AddMagicSqsEvents(sqsConfig); // SQSPublishers and handlers do not change when that line does. Publishing to two transports at once is not a
registration the framework ships; it is a composite IEventDispatcher you write, a few lines against the
same interface — and publishers and handlers still do not change.
Dispatch is fire-and-forget on all three, deliberately. In-process handlers run on a background task rather than blocking the caller, because that is what Kafka and SQS do, and a handler that only works when dispatch blocks would break on the day you switch.
An event means "this happened — carry on". Anything that must happen belongs in the use case. Handlers are
the side processes — email, metadata, search, the next workflow — and they are eventually consistent on
purpose, so each one should be safe to run twice. Retries, dead letters and delivery belong to whoever
builds the transport adapter, not to the code that raised the event. The one operation that genuinely needs
a particular bus can take that bus directly; the default path stays on IEventDispatcher. Each transport's
README states exactly what it does and does not guarantee.
TimeProvider — .NET's own clock abstraction — instead of DateTime.Now, so a test can move time rather than
wait for it. IKeyGenService gives
Snowflake ids — 64-bit, time-sortable, assigned before the insert, so a caller knows an entity's id without
a round trip and two instances never collide without coordinating. For keys that appear in a URL there are
unguessable string keys instead.
Background jobs schedule from the clock rather than from when the last run finished, so an hourly job stays on the hour instead of drifting by however long each run took:
public class ExpireHoldsBackgroundService(
IServiceScopeFactory serviceScopeFactory,
TimeProvider timeProvider,
ILogger<ExpireHoldsBackgroundService> logger)
: ScheduledBackgroundService(
serviceScopeFactory, new IntervalSchedule(TimeSpan.FromHours(1)), null, timeProvider, logger)
{
protected override string ScheduleKey => "expire-holds";
protected override string ServiceName => nameof(ExpireHoldsBackgroundService);
protected override async Task ExecuteScheduledTask(CancellationToken stoppingToken)
{
// A scope per run, because the job outlives any one of them.
await using var scope = ServiceScopeFactory.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<IExpireHoldsUseCase>().Execute();
}
}The job calls a use case. The schedule is not a place to hide business logic.
TimeOfDaySchedule is the other one, and takes a timezone, so "2am local" survives a daylight-saving
change. Every instance wakes on the same boundary and takes a distributed lock named for the job, so only
one runs it — the defaults suit a single machine, and running several means registering a lock provider and
a schedule store that they share.
A use case throws what it means. NotFoundException from the domain becomes a 404 with a problem+json body;
EntityConflictException a 409 and EntityInvalidOperationException — the entity's state refuses it, like
paying a charge twice — a 422; a ValidationException or a bad argument becomes a 400; anything unexpected becomes a 500 whose detail is
logged rather than returned, because an unhandled exception's message routinely contains a connection string
or a row nobody should see. HttpException and its subclasses are there for the times a use case genuinely
means a status code.
public class GetOrderUseCase(IOrdersRepository ordersRepository) : IGetOrderUseCase
{
public Task<Order> Execute(long orderId)
{
return ordersRepository.GetOrThrow(orderId); // 404 if it is not there
}
}The body carries the same request id as the X-Request-ID header, so an id quoted in a bug report finds the
request in the logs.
Fourteen, split so you take only what you use. MagicCSharp.App bundles the four a web service needs, for
when you would rather not choose.
| Package | Add it when you want | Brings with it |
|---|---|---|
| MagicCSharp.App | A web service wired in two calls | the four below it |
| MagicCSharp | Use cases, Snowflake ids, request IDs, TimeProvider registration |
DI + logging abstractions, IdGen |
| MagicCSharp.AspNetCore | Request-ID middleware, RFC 7807 error handling, startup preflight | the ASP.NET shared framework |
| MagicCSharp.Scheduling | Drift-free background jobs | DistributedLock, hosting |
| MagicCSharp.Data | Repository contracts, pagination, LINQ filter helpers | nothing — no persistence library |
| MagicCSharp.Data.EntityFramework | The repository base classes and DALs | EF Core |
| MagicCSharp.Data.Postgres | Pooled context factory, design-time factory, UTC interceptor | Npgsql |
| MagicCSharp.Events | Event dispatch and handler discovery | System.Text.Json |
| MagicCSharp.Events.Kafka | Kafka transport | Confluent.Kafka |
| MagicCSharp.Events.SQS | SQS transport | AWSSDK.SQS |
| MagicCSharp.Testing | FakeTimeProvider, FakeKeyGen, SyncEventDispatcher, in-memory locks |
DistributedLock, Microsoft's TimeProvider.Testing |
| MagicCSharp.Testing.Database | Repository tests against real PostgreSQL | Testcontainers, xUnit |
| MagicCSharp.Analyzers | The code-style conventions as compile errors | nothing — a development dependency, added by mcs init |
| MagicCSharp.Cli | The mcs tool |
installed globally, not referenced |
A domain project referencing MagicCSharp.Data gets the repository interfaces and no persistence library at
all — which is the point of the split. Wanting FakeTimeProvider does not mean wanting Docker.
Everything above works in any project, arranged however you like. The arrangement it was designed for is domain services in one repository, built from two kinds of thing:
| What it is | At MagicDoor | |
|---|---|---|
| App | A deployable service, with its own executable. It owns one business area: its data, endpoints and background work. | Maintenance, Auth, Accounting |
| Domain | A part of an app. An app's domains deploy together, in the app's one executable, but are kept apart inside it: each has its own use cases, entities, endpoints and tests. Domains of the same app may call each other through their use cases. | Vendors, MaintenanceRequests and VendorScheduling, inside Maintenance |
The apps share libraries, tooling and conventions, but not their data.
Domain services sit between a monolith and microservices. There are far fewer moving parts than a fleet of microservices, and one repository to change them in, yet each service still deploys, scales and fails on its own. Apps talk to each other through events, never through each other's databases. If you do want microservices, make the apps smaller; nothing about the layout changes.
Inside an app, the domains grow as a tree, each owning its use cases, entities, endpoints and tests:
Apps/
Shop/ an app: one deployable service
Shop.App/ Program.cs — a list of references and little else
Shop.Domains/Orders/ a domain inside it
Default/ use cases, event handlers
Models/ entities, edits, filters
App/ this domain's controllers
Tests/
Fulfilment/ a subdomain: the same shape, one level down
Data/
Data.Models/ repository interfaces — no EF dependency
Data.EntityFramework/ DALs, repositories, context, migrations
Notifications/ a second app: its own executable, domains and database
Notifications.App/
Notifications.Domains/
Recipients/ who to write to, kept from the events Shop publishes
Emails/ a second domain; may call Recipients' use cases
Data/
Data.Models/
Data.EntityFramework/
Libs/
Events/ the event contracts the apps share: Shop publishes, Notifications handles
A domain grows by gaining siblings rather than getting wider, and each brings its own endpoints, so
Shop.App never becomes the folder where every feature's controllers pile up. Where a class goes and what
it may reference are decided by the layout, enforced by the build and by mcs validate in CI, and generated
by a tool, so a service reads the same at a hundred use cases as it did at ten.
mcs is a dotnet global tool that creates and maintains it:
dotnet tool install -g MagicCSharp.Cli
mcs init --prefix Acme
mcs create-app --name Shop --database shop
mcs create-domain --solution Shop --name Orders --models --tests
mcs create-domain --solution Shop --name Orders.App
mcs add-entity --solution Shop --domain Orders --name Order --paginatedadd-entity writes the four files an entity needs, across three projects that each have to agree about
names, namespaces and generic arguments, and registers it — not typing saved so much as a class of mistake
removed. Nothing is ever overwritten, and re-running any command produces no diff.
mcs init also writes the conventions down for AI coding agents: an AGENTS.md — which Claude Code, Codex,
Cursor and the rest all read, CLAUDE.md importing it — and an .ai-knowledge/
folder, one guide per topic — where a use case goes, how an entity is shaped, how events, background services
and tests work — plus a project.md for what is specific to your repository. An agent working in the
repository follows the same rules the build enforces, and mcs update ai-files brings in improved guides with
each release. Their source is AIAgents/.
The full guide → — domains, subdomains, app libraries, entities, and what the tool wires versus what it leaves you. Template overrides → — every file it generates comes from a template you can replace, one at a time, keeping the rest.
The .NET 10 SDK. The libraries target net9.0; the CLI, the tests and generated repositories target net10.0.
PostgreSQL for the data packages. Docker only for MagicCSharp.Testing.Database.
This is how MagicDoor, Revoco and AgentParley write their C# services: the same use-case shape, the same ports, the same tests. It was extracted from MagicDoor's backend so the next service starts from the same defaults rather than a folder copied from the last one.
The engineers who designed it came from Amazon and Google. The rule they kept: an operation is a class you
can hold in your head, and infrastructure stays behind an interface. If you cannot test it with
new CreateLeaseUseCase(...), the operation is too big.
The opinions are not theoretical. They are what was left after finding out which pieces survive a codebase getting large and a team changing. Where a decision has a cost, the cost is written next to it. Judge them on that.
The framework is small on purpose — issues, template changes and new validate rules are all welcome.
CONTRIBUTING.md covers building it, running the tests without Docker, and what to raise
before writing code. GOVERNANCE.md says who maintains it and how breaking changes are
versioned. To report a vulnerability see SECURITY.md — please do not open a public issue.
MIT. See LICENSE.