Skip to content

Concepts & pipeline

This page goes over the core concepts in NSchema in more detail.

NSchema works around a simple domain model of schemas, tables, columns, indexes, constraints, and views. The model is designed to be flexible enough to represent the features of any relational database, while still being simple and intuitive to work with.

These models are used to represent both the desired state (what you want) and the current state (what the database has), so they can be compared symmetrically and transformed as needed.

Because they’re just .NET objects, the schema can be constructed in any way you like. The usual source is a SQL DDL file (see Defining schemas), but you could just as easily generate it from code, or even construct it from the database itself.

This section goes over the high-level pipeline steps that every NSchema application flows through. Most stages have an interface you can swap or extend; these are covered in more detail below and in Extension points.

This section of the pipeline is where the migration plan is generated. It runs on every execution, even for an Apply, so that stale plans aren’t accidentally applied.

  1. Resolve desired schemas. Load the target schema(s) from one or more registered sources.
  2. Combine desired schemas. Combine the desired schemas into a single database schema.
  3. Validate the desired schema. Run any registered schema policies to validate things like missing foreign keys, tables without columns, etc.
  4. Read current state. Load the current schema from the live database or the state store, depending on the operation and what’s configured.
  5. Compare schemas. The current and desired schemas are compared to produce a MigrationPlan.
  6. Transform the plan. Any custom transformations are applied to the plan. This is where actions are reordered to respect dependencies, or where custom actions are injected.
  7. Validate the plan. Validate the plan using any registered policies. If configured, the built-in destructive actions policy will error on any destructive actions.
  8. Compile the plan. The migration plan is compiled into an executable unit of work.

This section runs only for an Apply operation. It takes the compiled plan and executes it against the database.

  1. Execute the migration. Takes the compiled migration from the Planning phase, and executes it against the target.
  2. State capture. After a successful apply, the resulting schema is captured to the state store (if configured) so that future plans can be generated against it.

The Refresh operation captures the current live schema to the state store without doing any planning or applying. This is useful for recording drift that happened between applies, or for initializing the state store with the current schema.

Desired state is declared through one or more ISchemaProvider implementations. Multiple desired providers are supported and will be combined into a single schema, merging declared schemas of the same name. This lets you organize schemas by feature or bounded context and have them merged at runtime.

Current state is accessed through ICurrentSchemaProvider, which wraps two optional sources:

  • Online. The live database, registered via UseCurrentSchema<T>() or a provider package like UseCurrentSchemaPostgres(...).
  • Offline. A persisted snapshot, enabled automatically when a ISchemaStateStore is registered via UseStateStore<T>().

Plan operations prefer the offline source when available (so planning works without a database connection); Apply always reads from the live database.

The ISchemaProvider.GetSchema(...) method takes an optional list of schema names to read. When null or empty, the provider is expected to return its full schema. This allows for scoping to a subset of schemas when needed.

By default, the scope of a migration is equal to the full set of schemas returned by the registered desired providers, but it can also be scoped explicitly per run by setting the Schemas argument on the operation (e.g. app.Plan(new PlanArguments { Schemas = ["app"] })).

Schema policies are used to validate the desired schema before any comparison or planning is done. This is where you can enforce naming conventions, required columns, banned types, or any other rules you want to apply to your schema.

Schema policies are implemented using ISchemaPolicy and are registered with AddSchemaPolicy<T>(). If the policy returns any errors, execution will halt, preventing bad schemas from being applied.

The schema comparer is responsible for taking the current and desired schemas and producing a migration plan. The migration plan takes the form of a list of actions to perform, such as creating or dropping tables, adding or removing columns, etc.

The default comparer supports all the core features of the domain model, but you can replace it with your own implementation of ISchemaComparer if you have special requirements.

Diff policies validate the structured diff between the current and desired schema before a plan is built. This is where you enforce rules about what kinds of changes are allowed — for example, preventing destructive actions like dropping tables or columns (the built-in destructive-action policy lives here).

Diff policies are implemented using IDiffPolicy and registered with AddDiffPolicy<T>(). If any policy returns errors, execution will halt, preventing bad changes from being applied.

Once the plan is validated, NSchema turns it into SQL and, for an apply, runs it. These are two separate steps, so a plan can be previewed without a live connection:

  • ISqlGenerator turns the plan into a SqlPlan. This is pure string-building, so the SQL preview works offline; it’s typically implemented in database providers like NSchema.Postgres and registered with AddSqlGenerator<T>(). Each generator declares a Dialect; register several and choose one per run with WithDialect(...).
  • ISqlExecutor runs the SqlPlan against the database, applying the configured transaction mode. It is the only online step.

The rendered preview is produced by ISqlPlanRenderer (default DefaultSqlPlanRenderer), which the reporter owns — register a custom one to change the preview format, mirroring how IDiffRenderer controls the diff output.