Skip to Content
Genexy IDE Beta 0.9.1 · Download the Beta build
Features · Code generation

ASP.NET Core (`aspnet-core`)

PROIDEExperimental

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

Optiongnx create flagDefaultNotes
dotnetVersion--dotnet10.010.0, 9.0 or 8.0: the target framework and the image of the Dockerfile
rootNamespace--namespacethe id in PascalCasePrefix of every namespace and project, and the name of the solution
name, description, versionthe id, –, 1.0.0
basePathfrom the contractPath prefix of every contract; otherwise taken from servers[0].url
dependencies, testDependenciesExtra NuGet packages as Package.Id:Version. The version is required
businessErrorsname, code (HTTP status, default 400) and description of each error
exposes--port, --contractone port, 8080Ports: see Ports, Middleware & Security
exposes[].middleware, .validation, .securityMiddleware, validation, authentication
modulesDatabases, REST clients and security modules, see below

Modules

TypeContractWhat you get
postgresql-adaptersqlschemamodules/db-<name> on Npgsql: rows, repositories, queries, transactions, migrations
mysql-adapter (also mariadb-adapter)sqlschemaThe same on MySqlConnector. It is generated and compiles, but is not yet tested against a running server
rest-clientopenapimodules/client-<name>: typed I<Tag>Client interfaces of another API. baseUrl is optional
securityJWT / 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 in app/AppServices.cs:

    static partial void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddSingleton<IPriceCalculator, PriceCalculator>(); }
  • Parameters: path parameters, then <Op>Params with the query, header and cookie parameters, then the body (or the form of a multipart request with UploadedFile parts).

  • Errors: throw ApiException (BadRequest, NotFound, Conflict, Forbidden, Unprocessable…) or ApiException.WithBody(status, message, body) to send the error model of the contract. A BusinessException with a BusinessErrorCode from businessErrors is answered with the code’s status and its name as code. See Errors on the wire.

  • Types: date is DateOnly, date-time DateTimeOffset, time TimeOnly, duration TimeSpan, uuid Guid, number decimal; models are sealed classes with required for required properties; enums are written with the values of the contract.

Databases

Interface (<Ns>.Db.<Name>)Methods
I<Row>RepositoryFindByIdAsync, ExistsByIdAsync, FindAllAsync, FindPageAsync, CountAsync, FindBy<Column>Async, InsertAsync, UpdateAsync, DeleteByIdAsync
I<Db>QueriesOne method per named query of the contract
I<Db>TransactionsInTransactionAsync(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

  1. app/genexy.json (generated: the values of the spec; durations in seconds)
  2. app/appsettings.json, appsettings.<Environment>.json (yours), user secrets
  3. environment variables (GENEXY__PORTS__API__PORT), command line
  4. GenexyAppOptions in tests (o.Set(key, value), o.Port(name, 0))

Read your own settings with IConfiguration or the options pattern in AppServices.cs.

Tests

FileOwnerChecks
Generated/ArchitectureTestsgeneratedapp/ uses only the public API of the modules
Generated/PortsTestsgeneratedWith several ports: every operation answers only on its own port
Generated/TestAppgeneratedStarts the application for a test, every port on a free number
AppTests.csyoursStarts 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 app

Add 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
  • oneOf and discriminators (generated as JsonNode), response headers and several success statuses, readOnly / writeOnly, streaming
  • apiKey and basic security schemes, Keycloak in compose.yaml
  • A generated Dockerfile (docker.enabled) and Kubernetes manifests; today the Dockerfile is a starter you own