diff --git a/config/staticconfig/analyze.go b/config/staticconfig/analyze.go
index fb9f7a3..087c33c 100644
--- a/config/staticconfig/analyze.go
+++ b/config/staticconfig/analyze.go
@@ -108,7 +108,7 @@ func Analyze(pkgs []*packages.Package) Analysis {
 		},
 	}
 
-	if !findDogma(ctx) {
+	if !resolveDogmaPackage(ctx) {
 		// If the dogma package is not found as an import, none of the packages
 		// can possibly have types that implement [dogma.Application] because
 		// doing so requires referring to [dogma.ApplicationConfigurer].
diff --git a/config/staticconfig/application.go b/config/staticconfig/application.go
index 1c82f64..909899c 100644
--- a/config/staticconfig/application.go
+++ b/config/staticconfig/application.go
@@ -5,47 +5,36 @@ import (
 
 	"github.com/dogmatiq/enginekit/config"
 	"github.com/dogmatiq/enginekit/config/internal/configbuilder"
-	"github.com/dogmatiq/enginekit/internal/typename"
 )
 
 // analyzeApplicationType analyzes t, which must be an implementation of
 // [dogma.Application].
 func analyzeApplicationType(ctx *context, t types.Type) {
-	ctx.Analysis.Applications = append(
-		ctx.Analysis.Applications,
-		configbuilder.Application(
-			func(b *configbuilder.ApplicationBuilder) {
-				b.SetSourceTypeName(typename.OfStatic(t))
-
-				for call := range findConfigurerCalls(ctx, b, t) {
-					switch call.Method.Name() {
-					case "Identity":
-						analyzeIdentityCall(b, call)
-					case "RegisterAggregate":
-						b.Aggregate(func(b *configbuilder.AggregateBuilder) {
-							b.UpdateFidelity(call.Fidelity)
-							analyzeAggregate(ctx, b, call.Args[0])
-						})
-					case "RegisterProcess":
-						b.Process(func(b *configbuilder.ProcessBuilder) {
-							b.UpdateFidelity(call.Fidelity)
-							analyzeProcess(ctx, b, call.Args[0])
-						})
-					case "RegisterIntegration":
-						b.Integration(func(b *configbuilder.IntegrationBuilder) {
-							b.UpdateFidelity(call.Fidelity)
-							analyzeIntegration(ctx, b, call.Args[0])
-						})
-					case "RegisterProjection":
-						b.Projection(func(b *configbuilder.ProjectionBuilder) {
-							b.UpdateFidelity(call.Fidelity)
-							analyzeProjection(ctx, b, call.Args[0])
-						})
-					default:
-						b.UpdateFidelity(config.Incomplete)
-					}
-				}
-			},
-		),
+	app := configbuilder.Application(
+		func(b *configbuilder.ApplicationBuilder) {
+			analyzeEntity(
+				ctx,
+				t,
+				b,
+				analyzeApplicationConfigurerCall,
+			)
+		},
 	)
+
+	ctx.Analysis.Applications = append(ctx.Analysis.Applications, app)
+}
+
+func analyzeApplicationConfigurerCall(ctx *configurerCallContext[*configbuilder.ApplicationBuilder]) {
+	switch ctx.Method.Name() {
+	case "RegisterAggregate":
+		analyzeHandler(ctx, ctx.Builder.Aggregate, nil)
+	case "RegisterProcess":
+		analyzeHandler(ctx, ctx.Builder.Process, nil)
+	case "RegisterIntegration":
+		analyzeHandler(ctx, ctx.Builder.Integration, nil)
+	case "RegisterProjection":
+		analyzeHandler(ctx, ctx.Builder.Projection, analyzeProjectionConfigurerCall)
+	default:
+		ctx.Builder.UpdateFidelity(config.Incomplete)
+	}
 }
diff --git a/config/staticconfig/configurer.go b/config/staticconfig/configurer.go
deleted file mode 100644
index 07f0230..0000000
--- a/config/staticconfig/configurer.go
+++ /dev/null
@@ -1,162 +0,0 @@
-package staticconfig
-
-import (
-	"go/types"
-	"iter"
-
-	"github.com/dogmatiq/enginekit/config"
-	"github.com/dogmatiq/enginekit/config/internal/configbuilder"
-	"github.com/dogmatiq/enginekit/config/staticconfig/internal/ssax"
-	"golang.org/x/tools/go/ssa"
-)
-
-// configureContext is a specialization of [context] that is used when analyzing
-// a Configure() method.
-type configureContext struct {
-	*context
-
-	Func              *ssa.Function
-	Builder           configbuilder.EntityBuilder
-	ConfigurerIndices []int
-}
-
-func (c *configureContext) IsConfigurer(v ssa.Value) bool {
-	for _, i := range c.ConfigurerIndices {
-		if v == c.Func.Params[i] {
-			return true
-		}
-	}
-
-	return false
-}
-
-type configurerCall struct {
-	*ssa.CallCommon
-
-	Instruction ssa.CallInstruction
-	Fidelity    config.Fidelity
-}
-
-// analyzeConfigurerCalls analyzes the calls to the "configurer" that is passed
-// to t's "Configure()" method.
-//
-// Any calls that are not recognized are yielded.
-func findConfigurerCalls(
-	ctx *context,
-	b configbuilder.EntityBuilder,
-	t types.Type,
-) iter.Seq[configurerCall] {
-	configure := ctx.LookupMethod(t, "Configure")
-
-	return func(yield func(configurerCall) bool) {
-		emitConfigurerCallsInFunc(
-			&configureContext{
-				context:           ctx,
-				Func:              configure,
-				Builder:           b,
-				ConfigurerIndices: []int{1},
-			},
-			configure,
-			yield,
-		)
-	}
-}
-
-// emitConfigurerCallsInFunc yields all call to methods on the Dogma application
-// or handler "configurer" within the given function.
-//
-// indices is a list of the positions of parameters to fn that are the
-// configurer.
-func emitConfigurerCallsInFunc(
-	ctx *configureContext,
-	fn *ssa.Function,
-	yield func(configurerCall) bool,
-) bool {
-	if len(fn.Blocks) == 0 {
-		return true
-	}
-
-	for b := range ssax.WalkDown(fn.Blocks[0]) {
-		for _, inst := range b.Instrs {
-			if !emitConfigurerCallsInInstruction(ctx, inst, yield) {
-				return false
-			}
-		}
-	}
-
-	return true
-}
-
-func emitConfigurerCallsInInstruction(
-	ctx *configureContext,
-	inst ssa.Instruction,
-	yield func(configurerCall) bool,
-) bool {
-	switch inst := inst.(type) {
-	case ssa.CallInstruction:
-		return emitConfigurerCallsInCallInstruction(ctx, inst, yield)
-	default:
-		return true
-	}
-}
-
-func emitConfigurerCallsInCallInstruction(
-	ctx *configureContext,
-	call ssa.CallInstruction,
-	yield func(configurerCall) bool,
-) bool {
-	com := call.Common()
-
-	if com.IsInvoke() && ctx.IsConfigurer(com.Value) {
-		// We've found a direct call to a method on the configurer.
-		var f config.Fidelity
-		if !ssax.IsUnconditional(call.Block()) {
-			f |= config.Speculative
-		}
-
-		return yield(configurerCall{com, call, f})
-	}
-
-	// We've found a call to some function or method that does not belong to the
-	// configurer. If any of the arguments are the configurer we analyze the
-	// called function as well.
-	//
-	// This is an quite naive implementation. There are other ways that the
-	// callee could gain access to the configurer. For example, it could be
-	// passed inside a context, or assigned to a field within the entity struct.
-	//
-	// First, we build a list of the indices of arguments that are the
-	// configurer. It doesn't make much sense, but the configurer could be
-	// passed in multiple positions.
-	var indices []int
-	for i, arg := range com.Args {
-		if ctx.IsConfigurer(arg) {
-			indices = append(indices, i)
-		}
-	}
-
-	// If none of the arguments are the configurer, we can skip analyzing the
-	// callee. This prevents us from analyzing the entire program.
-	if len(indices) == 0 {
-		return true
-	}
-
-	// If we can't obtain the callee, this is a call to an interface method, or
-	// some other un-analyzable function.
-	fn := com.StaticCallee()
-	if fn == nil {
-		ctx.Builder.UpdateFidelity(config.Incomplete)
-		return true
-	}
-
-	return emitConfigurerCallsInFunc(
-		&configureContext{
-			context:           ctx.context,
-			Func:              fn,
-			Builder:           ctx.Builder,
-			ConfigurerIndices: indices,
-		},
-		fn,
-		yield,
-	)
-}
diff --git a/config/staticconfig/context.go b/config/staticconfig/context.go
index 09db393..abd475a 100644
--- a/config/staticconfig/context.go
+++ b/config/staticconfig/context.go
@@ -25,10 +25,10 @@ type context struct {
 	Analysis *Analysis
 }
 
-// findDogma updates ctx with information about the Dogma package.
+// resolveDogmaPackage updates ctx with information about the Dogma package.
 //
 // It returns false if the Dogma package has not been imported.
-func findDogma(ctx *context) bool {
+func resolveDogmaPackage(ctx *context) bool {
 	for _, pkg := range ctx.Program.AllPackages() {
 		if pkg.Pkg.Path() != "github.com/dogmatiq/dogma" {
 			continue
diff --git a/config/staticconfig/entity.go b/config/staticconfig/entity.go
new file mode 100644
index 0000000..4296754
--- /dev/null
+++ b/config/staticconfig/entity.go
@@ -0,0 +1,183 @@
+package staticconfig
+
+import (
+	"go/types"
+
+	"github.com/dogmatiq/enginekit/config"
+	"github.com/dogmatiq/enginekit/config/internal/configbuilder"
+	"github.com/dogmatiq/enginekit/config/staticconfig/internal/ssax"
+	"github.com/dogmatiq/enginekit/internal/typename"
+	"golang.org/x/tools/go/ssa"
+)
+
+type entityContext[T configbuilder.EntityBuilder] struct {
+	*context
+
+	EntityType             types.Type
+	Builder                T
+	ConfigureMethod        *ssa.Function
+	FunctionUnderAnalysis  *ssa.Function
+	ConfigurerParamIndices []int
+}
+
+func (c *entityContext[T]) IsConfigurer(v ssa.Value) bool {
+	for _, i := range c.ConfigurerParamIndices {
+		if v == c.FunctionUnderAnalysis.Params[i] {
+			return true
+		}
+	}
+	return false
+}
+
+type configurerCallContext[T configbuilder.EntityBuilder] struct {
+	*entityContext[T]
+	*ssa.CallCommon
+
+	Instruction ssa.CallInstruction
+	Fidelity    config.Fidelity
+}
+
+// configurerCallAnalyzer is a function that analyzes a call to a method on an
+// entity's configurer.
+type configurerCallAnalyzer[T configbuilder.EntityBuilder] func(*configurerCallContext[T])
+
+// analyzeEntity analyzes the Configure() method of the type t, which must be a
+// Dogma application or handler.
+//
+// It calls the analyze function for each call to a method on the configurer,
+// other than Identity() which is handled the same in all cases.
+func analyzeEntity[T configbuilder.EntityBuilder](
+	ctx *context,
+	t types.Type,
+	builder T,
+	analyze configurerCallAnalyzer[T],
+) {
+	builder.SetSourceTypeName(typename.OfStatic(t))
+	configure := ctx.LookupMethod(t, "Configure")
+
+	analyzeConfigurerCallsInFunc(
+		&entityContext[T]{
+			context:                ctx,
+			EntityType:             t,
+			Builder:                builder,
+			ConfigureMethod:        configure,
+			FunctionUnderAnalysis:  configure,
+			ConfigurerParamIndices: []int{1},
+		},
+		func(ctx *configurerCallContext[T]) {
+			switch ctx.Method.Name() {
+			case "Identity":
+				analyzeIdentity(ctx)
+			default:
+				analyze(ctx)
+			}
+		},
+	)
+}
+
+// analyzeConfigurerCallsInFunc analyzes calls to methods on the configurer in
+// the function under analysis.
+func analyzeConfigurerCallsInFunc[T configbuilder.EntityBuilder](
+	ctx *entityContext[T],
+	analyze configurerCallAnalyzer[T],
+) {
+	for b := range ssax.WalkFunc(ctx.FunctionUnderAnalysis) {
+		for _, inst := range b.Instrs {
+			if inst, ok := inst.(ssa.CallInstruction); ok {
+				analyzeConfigurerCallsInInstruction(ctx, inst, analyze)
+			}
+		}
+	}
+}
+
+// analyzeConfigurerCallsInInstruction analyzes calls to methods on the
+// configurer in the given instruction.
+func analyzeConfigurerCallsInInstruction[T configbuilder.EntityBuilder](
+	ctx *entityContext[T],
+	inst ssa.CallInstruction,
+	analyze configurerCallAnalyzer[T],
+) {
+	com := inst.Common()
+
+	if com.IsInvoke() && ctx.IsConfigurer(com.Value) {
+		// We've found a direct call to a method on the configurer.
+		var f config.Fidelity
+		if !ssax.IsUnconditional(inst.Block()) {
+			f |= config.Speculative
+		}
+
+		analyze(&configurerCallContext[T]{
+			entityContext: ctx,
+			CallCommon:    com,
+			Instruction:   inst,
+			Fidelity:      f,
+		})
+
+		return
+	}
+
+	// We've found a call to some function or method that does not belong to the
+	// configurer. If any of the arguments are the configurer we analyze the
+	// called function as well.
+	//
+	// This is an quite naive implementation. There are other ways that the
+	// callee could gain access to the configurer. For example, it could be
+	// passed inside a context, or assigned to a field within the entity struct.
+	//
+	// First, we build a list of the indices of arguments that are the
+	// configurer. It doesn't make much sense, but the configurer could be
+	// passed in multiple positions.
+	var indices []int
+	for i, arg := range com.Args {
+		if ctx.IsConfigurer(arg) {
+			indices = append(indices, i)
+		}
+	}
+
+	// We don't analyze the callee if it is not passed the configurer.
+	if len(indices) == 0 {
+		return
+	}
+
+	// If we can't obtain the callee this is a call to an interface method or
+	// some other un-analyzable function.
+	fn := com.StaticCallee()
+	if fn == nil {
+		ctx.Builder.UpdateFidelity(config.Incomplete)
+		return
+	}
+
+	analyzeConfigurerCallsInFunc(
+		&entityContext[T]{
+			context:                ctx.context,
+			EntityType:             ctx.EntityType,
+			Builder:                ctx.Builder,
+			ConfigureMethod:        ctx.ConfigureMethod,
+			FunctionUnderAnalysis:  fn,
+			ConfigurerParamIndices: indices,
+		},
+		analyze,
+	)
+}
+
+func analyzeIdentity[T configbuilder.EntityBuilder](
+	ctx *configurerCallContext[T],
+) {
+	ctx.
+		Builder.
+		Identity(func(b *configbuilder.IdentityBuilder) {
+			b.UpdateFidelity(ctx.Fidelity)
+
+			if name, ok := ssax.AsString(ctx.Args[0]).TryGet(); ok {
+				b.SetName(name)
+			} else {
+				b.UpdateFidelity(config.Incomplete)
+			}
+
+			if key, ok := ssax.AsString(ctx.Args[1]).TryGet(); ok {
+				b.SetKey(key)
+			} else {
+				b.UpdateFidelity(config.Incomplete)
+			}
+		})
+}
diff --git a/config/staticconfig/handler.go b/config/staticconfig/handler.go
index 4c1eff3..cec6448 100644
--- a/config/staticconfig/handler.go
+++ b/config/staticconfig/handler.go
@@ -1,87 +1,68 @@
 package staticconfig
 
 import (
-	"iter"
-
 	"github.com/dogmatiq/enginekit/config"
 	"github.com/dogmatiq/enginekit/config/internal/configbuilder"
-	"github.com/dogmatiq/enginekit/internal/typename"
 	"golang.org/x/tools/go/ssa"
 )
 
-func analyzeHandler(
-	ctx *context,
-	b configbuilder.HandlerBuilder,
-	h ssa.Value,
-) iter.Seq[configurerCall] {
-	return func(yield func(configurerCall) bool) {
-		switch inst := h.(type) {
-		default:
-			b.UpdateFidelity(config.Incomplete)
-		case *ssa.MakeInterface:
-			t := inst.X.Type()
-			b.SetSourceTypeName(typename.OfStatic(t))
+func analyzeHandler[T configbuilder.HandlerBuilder](
+	ctx *configurerCallContext[*configbuilder.ApplicationBuilder],
+	build func(func(T)),
+	analyze configurerCallAnalyzer[T],
+) {
+	build(func(b T) {
+		b.UpdateFidelity(ctx.Fidelity)
 
-			for call := range findConfigurerCalls(ctx, b, t) {
-				switch call.Method.Name() {
-				case "Identity":
-					analyzeIdentityCall(b, call)
+		inst, ok := ctx.Args[0].(*ssa.MakeInterface)
+		if !ok {
+			ctx.Builder.UpdateFidelity(config.Incomplete)
+			return
+		}
+
+		analyzeEntity(
+			ctx.context,
+			inst.X.Type(),
+			b,
+			func(ctx *configurerCallContext[T]) {
+				switch ctx.Method.Name() {
 				case "Routes":
-					analyzeRoutesCall(ctx, b, call)
+					analyzeRoutes(ctx)
+
 				case "Disable":
-					b.SetDisabled(true)
+					// TODO(jmalloc): f is lost in this case, so any handler
+					// that is _sometimes_ disabled will appear as always
+					// disabled, which is a bit non-sensical.
+					//
+					// It probably needs similar treatment to
+					// https://github.com/dogmatiq/enginekit/issues/55.
+					ctx.Builder.SetDisabled(true)
+
 				default:
-					if !yield(call) {
-						return
+					if analyze == nil {
+						ctx.Builder.UpdateFidelity(config.Incomplete)
+					} else {
+						analyze(ctx)
 					}
 				}
-			}
+			},
+		)
 
-			// If the handler wasn't disabled, and the configuration is NOT
-			// incomplete, we know that the handler is enabled.
-			if !b.IsDisabled().IsPresent() && b.Fidelity()&config.Incomplete == 0 {
-				b.SetDisabled(false)
-			}
+		// If the handler wasn't disabled, and the configuration is NOT
+		// incomplete, we know that the handler is enabled.
+		if !b.IsDisabled().IsPresent() && b.Fidelity()&config.Incomplete == 0 {
+			b.SetDisabled(false)
 		}
-	}
-}
-
-func analyzeAggregate(
-	ctx *context,
-	b *configbuilder.AggregateBuilder,
-	h ssa.Value,
-) {
-	for call := range analyzeHandler(ctx, b, h) {
-		b.UpdateFidelity(call.Fidelity)
-	}
-}
-
-func analyzeProcess(
-	ctx *context,
-	b *configbuilder.ProcessBuilder,
-	h ssa.Value,
-) {
-	for call := range analyzeHandler(ctx, b, h) {
-		b.UpdateFidelity(call.Fidelity)
-	}
-}
-
-func analyzeIntegration(
-	ctx *context,
-	b *configbuilder.IntegrationBuilder,
-	h ssa.Value,
-) {
-	for call := range analyzeHandler(ctx, b, h) {
-		b.UpdateFidelity(call.Fidelity)
-	}
+	})
 }
 
-func analyzeProjection(
-	ctx *context,
-	b *configbuilder.ProjectionBuilder,
-	h ssa.Value,
+func analyzeProjectionConfigurerCall(
+	ctx *configurerCallContext[*configbuilder.ProjectionBuilder],
 ) {
-	for call := range analyzeHandler(ctx, b, h) {
-		b.UpdateFidelity(call.Fidelity)
+	switch ctx.Method.Name() {
+	case "DeliveryPolicy":
+		panic("not implemented") // TODO
+	default:
+		ctx.Builder.UpdateFidelity(config.Incomplete)
 	}
 }
diff --git a/config/staticconfig/identity.go b/config/staticconfig/identity.go
deleted file mode 100644
index 5d8c761..0000000
--- a/config/staticconfig/identity.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package staticconfig
-
-import (
-	"github.com/dogmatiq/enginekit/config"
-	"github.com/dogmatiq/enginekit/config/internal/configbuilder"
-	"github.com/dogmatiq/enginekit/config/staticconfig/internal/ssax"
-)
-
-func analyzeIdentityCall(
-	b configbuilder.EntityBuilder,
-	call configurerCall,
-) {
-	b.Identity(func(b *configbuilder.IdentityBuilder) {
-		b.UpdateFidelity(call.Fidelity)
-
-		if name, ok := ssax.AsString(call.Args[0]).TryGet(); ok {
-			b.SetName(name)
-		} else {
-			b.UpdateFidelity(config.Incomplete)
-		}
-
-		if key, ok := ssax.AsString(call.Args[1]).TryGet(); ok {
-			b.SetKey(key)
-		} else {
-			b.UpdateFidelity(config.Incomplete)
-		}
-	})
-}
diff --git a/config/staticconfig/internal/ssax/flow.go b/config/staticconfig/internal/ssax/flow.go
index 9a7f2ad..6eb81a7 100644
--- a/config/staticconfig/internal/ssax/flow.go
+++ b/config/staticconfig/internal/ssax/flow.go
@@ -6,6 +6,14 @@ import (
 	"golang.org/x/tools/go/ssa"
 )
 
+// WalkFunc recursively yields all reachable blocks in the given function.
+func WalkFunc(fn *ssa.Function) iter.Seq[*ssa.BasicBlock] {
+	if len(fn.Blocks) == 0 {
+		return func(func(*ssa.BasicBlock) bool) {}
+	}
+	return WalkDown(fn.Blocks[0])
+}
+
 // WalkDown recursively yields b and all reachable successor blocks of b.
 //
 // A block is considered reachable if there is a control flow path from b to
diff --git a/config/staticconfig/route.go b/config/staticconfig/route.go
index 2f33b64..50fb3cf 100644
--- a/config/staticconfig/route.go
+++ b/config/staticconfig/route.go
@@ -7,15 +7,13 @@ import (
 	"golang.org/x/tools/go/ssa"
 )
 
-func analyzeRoutesCall(
-	ctx *context,
-	b configbuilder.HandlerBuilder,
-	call configurerCall,
+func analyzeRoutes[T configbuilder.HandlerBuilder](
+	ctx *configurerCallContext[T],
 ) {
-	for r := range resolveVariadic(b, call) {
-		b.Route(func(b *configbuilder.RouteBuilder) {
-			b.UpdateFidelity(call.Fidelity)
-			analyzeRoute(ctx, b, r)
+	for r := range resolveVariadic(ctx.Builder, ctx.Instruction) {
+		ctx.Builder.Route(func(b *configbuilder.RouteBuilder) {
+			b.UpdateFidelity(ctx.Fidelity) // TODO: is this correct?
+			analyzeRoute(ctx.context, b, r)
 		})
 	}
 }
diff --git a/config/staticconfig/type.go b/config/staticconfig/type.go
index 42d0791..142bb3c 100644
--- a/config/staticconfig/type.go
+++ b/config/staticconfig/type.go
@@ -26,9 +26,9 @@ func isAbstract(t types.Type) bool {
 
 // analyzeType analyzes a type that was discovered within a package.
 //
-// THe currently implementation only looks for [dogma.Application]
-// implementations; handler implementations are ignored unless they are actually
-// used within an application.
+// The current implementation only looks for types that implement the
+// [dogma.Application] interface. Handler implementations are ignored unless
+// they are actually used within an application.
 func analyzeType(ctx *context, t types.Type) {
 	if isAbstract(t) {
 		// We're only interested in concrete types; otherwise there's nothing to
diff --git a/config/staticconfig/varargs.go b/config/staticconfig/varargs.go
index d241f57..72568f4 100644
--- a/config/staticconfig/varargs.go
+++ b/config/staticconfig/varargs.go
@@ -45,9 +45,11 @@ func isIndexOfArray(
 
 func resolveVariadic(
 	b configbuilder.EntityBuilder,
-	call configurerCall,
+	inst ssa.CallInstruction,
 ) iter.Seq[ssa.Value] {
 	return func(yield func(ssa.Value) bool) {
+		call := inst.Common()
+
 		variadics := call.Args[len(call.Args)-1]
 		if ssax.IsZeroValue(variadics) {
 			return
@@ -60,11 +62,11 @@ func resolveVariadic(
 		}
 
 		for b := range ssax.WalkDown(array.Block()) {
-			if !ssax.PathExists(b, call.Instruction.Block()) {
+			if !ssax.PathExists(b, inst.Block()) {
 				continue
 			}
 
-			for inst := range ssax.InstructionsBefore(b, call.Instruction) {
+			for inst := range ssax.InstructionsBefore(b, inst) {
 				switch inst := inst.(type) {
 				case *ssa.Store:
 					if _, ok := isIndexOfArray(array, inst.Addr); ok {