Skip to content
Kumar Chandrachooda
.NET

One appsettings.json, Read Top to Bottom

Convey's real manifest is not Program.cs — it is the config file. Reading the Orders sample's appsettings.json as a single artifact: the section-per-package convention, the fleet of enabled:false switches, and what the defaults quietly tell you about production.

By Kumar Chandrachooda 19 Jun 2026 6 min read
A config file as a table of contents

In the parent series I made a claim in part 1 that I never actually cashed in: that with Convey, “your appsettings.json becomes a table of contents for the service's capabilities.” Then I spent twenty-one parts quoting individual sections — "rabbitmq" in the broker parts, "vault" in the secrets part — and never once put the whole file on the table.

That was a mistake, because the Orders sample's appsettings.json is, at roughly 180 lines, the single most information-dense artifact in the Convey repository. It is sixteen top-level sections, one per composed capability, and reading it as one document teaches things that no per-package reading can. So that is this chapter: the file, top to bottom, as an artifact.

The shape: sixteen sections, one convention

Here is the section inventory of the Orders sample, in file order: app, consul, fabio, httpClient, logger, jaeger, metrics, mongo, prometheus, rabbitMq, swagger, redis, security, vault, webApi — plus outbox when the outbox is on. Every one binds to a plain options class through the same GetOptions<T>(sectionName) helper the builder part dissected. There is no schema, no validation layer, no IOptions<T> — a new TModel(), a Bind, done.

Which means the convention is the contract, and the convention has real teeth:

  1. Presence in the file ≈ presence in Program.cs. If there is a "consul" section, someone called AddConsul(). The file and the composition chain are two views of the same list, and drift between them is the first thing I audit on any Convey service I inherit.
  2. The section name is the package's true name. Docs say Convey.Discovery.Consul; the file says "consul". Six months in, everyone on the team speaks in section names.
  3. Unbound keys fail silently. Bind ignores anything the options class doesn't declare. Typo "retires": 3 and you get the default, no warning. The flatness that makes the file readable also makes it unverified — I have genuinely shipped that typo.

The enabled: false fleet

Read the sample file cold and one pattern jumps out: how much of it is off. consul.enabled: false. fabio.enabled: false. jaeger.enabled: false. prometheus.enabled: false. vault.enabled: false — and inside vault, kv, pki and the mongo lease are each individually false again.

This is a deliberate design position that the parent series never named: capabilities are composed in code but activated in config. The Add* call registers the machinery unconditionally; the options flag decides whether the machinery does anything. The sample ships with everything wired and almost everything dormant, so docker compose up mongo rabbitmq plus dotnet run gives you a working service, and each infrastructure dependency is one boolean away.

The cost of the pattern is equally real. enabled: false does not mean free — the registrations still happen, the types still load. And it means a service's effective architecture is not readable from code at all: two deployments of the same binary can be radically different systems. Your production consul.enabled: true lives in an environment override, and the file in the repo is now the dev view of the service, not the truth. Every configuration-over-composition system pays this bill; Convey just makes it unusually visible.

What the defaults are telling you

The middle of the file is where the operational war stories hide, one value at a time.

The rabbitMq section is the biggest by far — some sixty lines — and it is a syllabus of everything parts 9 and 10 covered, now with numbers attached: retries: 3, retryInterval: 2, deadLetter.prefix: "dlx-", conventionsCasing: "snakeCase", the queue template "{{assembly}}/{{exchange}}.{{message}}", and a block of six timeouts written as timespan strings"requestedConnectionTimeout": "00:00:30", "requestedHeartbeat": "00:01:00". Two things worth pinning. First, messagesPersisted: true and deadLetter.enabled: true are the sample telling you what the authors consider table stakes. Second, logger.logMessagePayload: true is the sample telling you what they consider acceptable in dev only — full payload logging is a compliance incident waiting for a production copy-paste.

The logger section demonstrates the exclusion idiom: "excludePaths": ["/ping", "/metrics"], repeated identically in the jaeger section. Health probes and scrapes are the noisiest, least interesting traffic a service has; Convey makes suppressing them a convention rather than a custom middleware. Note also that the file, not the code, decides the log level and sinks — console, file (daily interval), and Seq with a URL and token side by side.

About that token: the sample ships "token": "secret", and the vault section ships "token": "secret" too, next to username: "user" / password: "secret", while rabbitMq carries guest/guest. These are the containers' public dev defaults, and in a sample that is exactly right — but this file is what every new Convey service gets copied from, which makes it the number-one vector for dev credentials reaching an environment that matters. The Vault part of the parent series shows the actual answer; the point here is that the file layout is the reminder: the vault.kv block exists precisely so the logger.seq.token line can stop being a literal.

The vault.lease block is my favorite ten lines in the file:

"lease": {
  "mongo": {
    "type": "database",
    "roleName": "orders-service",
    "enabled": false,
    "autoRenewal": true,
    "templates": {
      "connectionString": "mongodb://{{username}}:{{password}}@localhost:27017"
    }
  }
}

A moustache template inside a config value, filled at startup with dynamic credentials Vault mints for this instance. When this block is on, the static mongo.connectionString above it becomes dead config — the two sections cooperate across the file, and nothing but your understanding connects them. This is the file at its most powerful and most implicit in the same breath.

The one-line sections

The tail of the file is small sections doing outsized work. "redis": { "connectionString": "localhost", "instance": "orders:" } — that instance prefix is the only namespacing between services sharing a Redis, a single string standing between you and cross-service cache collisions. "security".certificate.header: "Certificate" names the mTLS forwarding header from part 18. And the last section in the file is one boolean with a whole part of the parent series behind it:

"webApi": {
  "bindRequestFromRoute": true
}

That flag switches on the backing-field route binding that part 5 took apart — reflection writing orderId from the URL into an init-only property of your query object. The most magical behavior in the whole HTTP layer, controlled by the most anonymous line in the file. If you ever wondered whether config review matters as much as code review on a chassis: there it is.

Reading files like this on purpose

What the parent series treated as sixteen footnotes turns out to be a genre of document worth reading whole. My checklist when I open an unfamiliar Convey service's config now: diff the section list against the Add* chain (drift = archaeology); list the enabled flags and ask which environment flips each one; scan for literals that should be Vault paths; and read the RabbitMQ numbers — retries, intervals, dead-letter — as the previous team's incident history, because that is usually what they are.

One file, no code, and you know what the service talks to, what it ignores, what it logs, and what its authors were burned by. Next chapter: the package the parent series dismissed in a single paragraph — Convey's hand-rolled OpenStack object-storage client.