ASP.NET Core (`aspnet-core`)
Generates an ASP.NET Core Web API in C# as a modular solution. The app project holds your code. Every API contract,
every database and every REST client is a project of its own. There is also a platform project and a test project.
The endpoints are minimal APIs that validate the request themselves, without ASP.NET model binding. Databases use
ADO.NET with Npgsql or MySqlConnector, clients HttpClient. Tokens are verified with System.Security.Cryptography,
without the JwtBearer package. NuGet versions are pinned centrally in Directory.Packages.props, and the build runs
with warnings as errors.
Quick start
gnx create orders --type aspnet-core --dir services/orders --dotnet 10.0 --namespace Acme.Orders --contract services/orders/api/orders.openapi.yaml --port 8080--type dotnet is an alias. The component spec:
# services/orders/.genexy/component.yaml
type: aspnet-core
dotnetVersion: '10.0'
rootNamespace: Acme.Orders
exposes:
- name: api
port: 8080
contracts:
- $ref: api/orders.openapi.yaml
modules:
- name: main-db
type: postgresql-adapter
contract: { $ref: db/orders.yaml }
- name: payments
type: rest-client
contract: { $ref: ../payments/api/payments.openapi.yaml }
baseUrl: https://payments.example.com
businessErrors:
- { name: OUT_OF_STOCK, code: 409, description: Item is out of stock }Component spec
| Option | gnx create flag | Default | Notes |
|---|---|---|---|
dotnetVersion | --dotnet | 10.0 | 10.0, 9.0 or 8.0: the target framework and the image of the Dockerfile |
rootNamespace | --namespace | the id in PascalCase | Prefix of every namespace and project, and the name of the solution |
name, description, version | the id, –, 1.0.0 | ||
basePath | from the contract | Path prefix of every contract; otherwise taken from servers[0].url | |
dependencies, testDependencies | Extra NuGet packages as Package.Id:Version. The version is required | ||
businessErrors | name, code (HTTP status, default 400) and description of each error | ||
exposes | --port, --contract | one port, 8080 | Ports: see Ports, Middleware & Security |
exposes[].middleware, .validation, .security | Middleware, validation, authentication | ||
modules | Databases, REST clients and security modules, see below |
Modules
| Type | Contract | What you get |
|---|---|---|
postgresql-adapter | sqlschema | modules/db-<name> on Npgsql: rows, repositories, queries, transactions, migrations |
mysql-adapter (also mariadb-adapter) | sqlschema | The same on MySqlConnector. It is generated and compiles, but is not yet tested against a running server |
rest-client | openapi | modules/client-<name>: typed I<Tag>Client interfaces of another API. baseUrl is optional |
security | JWT / OIDC bearer tokens for the ports that use it. See Authentication |
With several databases, mark one primary: true. Redis and messaging modules are not generated yet. The generator
skips them with a warning.
What is generated
orders/
├─ .genexy/component.yaml the component spec (and generated.json)
├─ Acme.Orders.sln, Directory.Build.props, Directory.Packages.props generated
├─ compose.yaml generated with a database
├─ Dockerfile, .dockerignore, README.md yours (created once)
├─ app/
│ ├─ Acme.Orders.App.csproj, Program.cs, genexy.json generated
│ ├─ Generated/ generated: GenexyApp (bootstrap), Ports/
│ ├─ Orders/OrdersServiceImpl.cs stub, one per OpenAPI tag
│ ├─ AppServices.cs stub: register your own services
│ ├─ appsettings.json yours: your settings
│ └─ Migrations/main-db/ yours: your own migrations
├─ tests/Acme.Orders.Tests/
│ ├─ Generated/ generated: ArchitectureTests, PortsTests, TestApp
│ └─ AppTests.cs stub
└─ modules/ generated, never edit
├─ api-orders/ I<Tag>Service, Model/, Internal/ endpoints and schemas
├─ db-main-db/ I<Row>Repository, I<Db>Queries, I<Db>Transactions, Migrations/
├─ client-payments/ I<Tag>Client, Model/
└─ platform/ ApiException, BusinessException, RequestContext, Principal, Web/, Hosting/, Data/The namespaces for your code are <Ns>.Api.<Contract>, <Ns>.Db.<Database>, <Ns>.Clients.<Client> and
<Ns>.Platform. Code in app/ (outside Generated/ and Program.cs) must not use Microsoft.AspNetCore, Npgsql,
MySqlConnector or <Ns>.Platform.Web, .Hosting and .Data. The generated ArchitectureTests fail if it does, and
the implementations in the modules are internal anyway.
Writing the business logic
There is one <Tag>ServiceImpl per OpenAPI tag. The generated bootstrap registers it as a scoped service. Every method
is async and gets a CancellationToken, which is cancelled when the client goes away or the request times out. The
interface has default implementations that answer 501, so a new operation never breaks the build.
using Acme.Orders.Api.Orders;
using Acme.Orders.Api.Orders.Model;
using Acme.Orders.Clients.Payments;
using Acme.Orders.Db.MainDb;
using Acme.Orders.Platform;
namespace Acme.Orders.App.Orders;
public sealed class OrdersServiceImpl(IOrderRepository orders, IMainDbTransactions tx, IPaymentsClient payments) : IOrdersService
{
public async Task<Order> GetOrderAsync(long orderId, CancellationToken cancellationToken = default)
{
var row = await orders.FindByIdAsync(orderId, cancellationToken)
?? throw ApiException.NotFound($"order {orderId} not found");
if (RequestContext.Principal is { } caller && !caller.HasRole("admin") && row.Owner != caller.Subject)
{
throw ApiException.Forbidden("not your order");
}
return row.ToApi();
}
public async Task<Order> PlaceOrderAsync(NewOrder body, CancellationToken cancellationToken = default)
{
if (body.Items.Count == 0)
{
throw new BusinessException(BusinessErrorCode.OUT_OF_STOCK);
}
return await tx.InTransactionAsync(async ct =>
{
var row = await orders.InsertAsync(body.ToRow(), ct);
return row.ToApi();
}, cancellationToken);
}
}-
Dependencies: constructor injection of repositories, transactions, clients,
ILogger<T>and your own services. Register your services inapp/AppServices.cs:static partial void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddSingleton<IPriceCalculator, PriceCalculator>(); } -
Parameters: path parameters, then
<Op>Paramswith the query, header and cookie parameters, then the body (or the form of a multipart request withUploadedFileparts). -
Errors: throw
ApiException(BadRequest,NotFound,Conflict,Forbidden,Unprocessable…) orApiException.WithBody(status, message, body)to send the error model of the contract. ABusinessExceptionwith aBusinessErrorCodefrombusinessErrorsis answered with the code’s status and its name ascode. See Errors on the wire. -
Types:
dateisDateOnly,date-timeDateTimeOffset,timeTimeOnly,durationTimeSpan,uuidGuid,numberdecimal; models aresealed classes withrequiredfor required properties; enums are written with the values of the contract.
Databases
Interface (<Ns>.Db.<Name>) | Methods |
|---|---|
I<Row>Repository | FindByIdAsync, ExistsByIdAsync, FindAllAsync, FindPageAsync, CountAsync, FindBy<Column>Async, InsertAsync, UpdateAsync, DeleteByIdAsync |
I<Db>Queries | One method per named query of the contract |
I<Db>Transactions | InTransactionAsync(work), ReadOnlyAsync(work): repository calls inside join the transaction |
Rows are sealed records. A constraint violation throws DataConflictException (409 with the constraint name), a
missing row RowNotFoundException (404).
Migrations: Migrations/V<n>__*.sql of the module are embedded in its assembly. The first generation writes
V1__init.sql, every later change of the schema the next one, from the difference to schema.snapshot.json. They are
never rewritten. Before the ports open, the application waits for the database, takes a lock and applies the
migrations, recording them with a checksum in genexy_schema_history. Your own migrations go into
app/Migrations/<name>/.
Settings: Genexy:Databases:<name>:Url, Username, Password, Migrate, ConnectTimeout, or the environment
variables <NAME>_URL, <NAME>_USERNAME, <NAME>_PASSWORD.
REST clients
A rest-client module gives I<Tag>Client interfaces with the same methods as the server of that contract, so a test
can replace a client with a fake. Inject them through the constructor. The base URL is the environment variable
<NAME>_URL (PAYMENTS_URL), else Genexy:Clients:<name>:BaseUrl, whose generated default is the baseUrl of the
spec or servers[0].url of the contract. :Timeout is in seconds (default 30). An error status, a timeout or an unreachable
service throws ClientException, which is answered with 502 when you let it through. The X-Request-ID of the
current request is forwarded.
Ports, middleware and security
Every port is a Kestrel endpoint. Each connection carries the name of its port, and an endpoint answers only requests
of its own port. The middleware of each port runs in this order: compression, problem details for 404 and 405,
request id, security headers, CORS, body limit, authentication and authorization, rate limit, request timeout,
validation options, access log. Each endpoint carries the security of its operation as metadata
(OperationSecurity). All options: Ports, Middleware & Security.
var caller = RequestContext.Principal; // null: anonymous (public operation or a port without security)
logger.LogInformation("{User} ({Subject}) roles {Roles}", caller?.Username, caller?.Subject, caller?.Roles);Configuration
app/genexy.json(generated: the values of the spec; durations in seconds)app/appsettings.json,appsettings.<Environment>.json(yours), user secrets- environment variables (
GENEXY__PORTS__API__PORT), command line GenexyAppOptionsin tests (o.Set(key, value),o.Port(name, 0))
Read your own settings with IConfiguration or the options pattern in AppServices.cs.
Tests
| File | Owner | Checks |
|---|---|---|
Generated/ArchitectureTests | generated | app/ uses only the public API of the modules |
Generated/PortsTests | generated | With several ports: every operation answers only on its own port |
Generated/TestApp | generated | Starts the application for a test, every port on a free number |
AppTests.cs | yours | Starts the application and calls /healthz on every port |
await using var app = await TestApp.StartAsync(o => o.Set("Genexy:Security:auth:JwksUrl", jwksUrl));
using var http = new HttpClient { BaseAddress = app.Ports.Url("api") };
var response = await http.GetAsync("/api/v1/orders/1");With a database, TestApp.Available is true only with GENEXY_TEST_DATABASES=external and a running database, for
example from the generated compose.yaml (docker compose up -d).
Build and run
Needs the .NET SDK of the chosen version. In the component folder:
dotnet build
dotnet test
dotnet run --project appAdd NuGet packages with dependencies in the component spec. The generator owns the .csproj files and
Directory.Packages.props.
Not generated yet
- WebSocket clients and servers, MariaDB as its own dialect, PostGIS, Oracle, MongoDB, Redis, Kafka and NATS modules
- Retries, circuit breakers and authentication of REST clients
oneOfand discriminators (generated asJsonNode), response headers and several success statuses,readOnly/writeOnly, streamingapiKeyandbasicsecurity schemes, Keycloak incompose.yaml- A generated Dockerfile (
docker.enabled) and Kubernetes manifests; today the Dockerfile is a starter you own