Factory Method and Static Factories: Delegating Creation to Subclasses or a Single Method
The GoF Factory Method leaves the choice of product to a subclass; a static factory method is a single named method that replaces a constructor, can cache and can return a hidden subtype. The factory-method-in-constructor bug and CA2214, DbConnection.CreateDbCommand, ILoggerFactory and IHttpClientFactory, Func<T>, ActivatorUtilities and keyed services in DI, and ownership of IDisposable products, with real output.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
The Bookstore accounting team wants the monthly sales report as CSV (Comma-Separated Values), with a semicolon as the separator, because Excel's Turkish settings treat the comma as the decimal separator. The report job's workflow lives in a base class; a subclass decides which exporter to use, and the base class makes that call in its constructor. The code compiles and runs. What do you see in the output?
using System.Globalization;
using Bookstore.Reporting;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Sale[] sales =
[
new("Dune", 2, 25.98m),
new("Emma", 1, 7.50m)
];
ReportJob job = new CsvReportJob(separator: ";");
Console.WriteLine(job.Run(sales));
namespace Bookstore.Reporting
{
public record Sale(string Title, int Quantity, decimal Total);
public interface IReportExporter
{
string Export(IReadOnlyList<Sale> sales);
}
public sealed class CsvExporter(string separator) : IReportExporter
{
public string Export(IReadOnlyList<Sale> sales) =>
string.Join(Environment.NewLine, sales.Select(s => $"{s.Title}{separator}{s.Quantity}{separator}{s.Total}"));
}
// Creator: knows the workflow, leaves the choice of exporter to the subclass
public abstract class ReportJob
{
private readonly IReportExporter _exporter;
protected ReportJob()
{
_exporter = CreateExporter(); // factory method
}
protected abstract IReportExporter CreateExporter();
public string Run(IReadOnlyList<Sale> sales) => _exporter.Export(sales);
}
public sealed class CsvReportJob : ReportJob
{
private readonly string _separator;
public CsvReportJob(string separator)
{
_separator = separator;
}
protected override IReportExporter CreateExporter() => new CsvExporter(_separator);
}
}
A Factory Method Called from the Constructor
Output:
Dune225.98
Emma17.50
No separator. Instead of Dune;2;25.98 we got Dune225.98, and the accountant who opens the file cannot tell which digits are the quantity and which are the total. The cause is C#'s constructor order: new CsvReportJob(";") runs the base class constructor first, and CsvReportJob's constructor body only runs after it. When the base constructor calls CreateExporter(), virtual dispatch goes to the subclass override, but at that moment _separator has not been assigned yet; its value is null, and string.Join treats it as an empty string. Nullable reference types do not help either, because from the compiler's point of view the field is assigned in the constructor.
The compiler stays silent, but one of .NET's code analysis rules recognises the situation. With an .editorconfig containing dotnet_diagnostic.CA2214.severity = warning in the project folder, the build says:
Program.cs(37,25): warning CA2214: Do not call overridable methods in constructors (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2214) [app.csproj]
An interesting detail: write the subclass with a primary constructor and the bug disappears.
// Primary constructor: the captured parameter is assigned before the base constructor runs
public sealed class CsvReportJob(string separator) : ReportJob
{
protected override IReportExporter CreateExporter() => new CsvExporter(separator);
}
Dune;2;25.98
Emma;1;7.50
A primary constructor's captured parameters are assigned before the base constructor call, like field initializers. Do not rely on it. The next developer who starts computing a value in the constructor body brings the bug back. The real fix is not to call the factory method from the constructor: create the product when it is needed, inside Run.
The GoF Factory Method: Leaving the Decision to a Subclass
Factory Method is the only class-scope pattern among the creational patterns of the GoF (Gang of Four: Gamma, Helm, Johnson and Vlissides, authors of the 1994 Design Patterns book). In short, its intent: define an interface for creating an object, but let subclasses decide which concrete class to instantiate. It has four roles: the product's interface (Product, here IReportExporter), the concrete products (CsvExporter, MarkdownExporter), the creator that knows the workflow and declares the factory method (ReportJob), and the concrete creators that override it. The fixed version:
using System.Data.Common;
using System.Globalization;
using System.Reflection;
using Bookstore.Reporting;
using Microsoft.Data.Sqlite;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Sale[] sales =
[
new("Dune", 2, 25.98m),
new("Emma", 1, 7.50m)
];
foreach (ReportJob job in new ReportJob[] { new CsvReportJob(";"), new MarkdownReportJob() })
{
ExportedFile file = job.Run(sales);
Console.WriteLine($"--- {file.Name}");
Console.WriteLine(file.Content);
}
// The factory method in the BCL: DbConnection.CreateCommand -> protected abstract CreateDbCommand
MethodInfo hook = typeof(DbConnection).GetMethod("CreateDbCommand", BindingFlags.NonPublic | BindingFlags.Instance)!;
Console.WriteLine($"--- DbConnection.CreateDbCommand: abstract={hook.IsAbstract}, protected={hook.IsFamily}");
using DbConnection connection = new SqliteConnection("Data Source=:memory:");
using DbCommand command = connection.CreateCommand();
Console.WriteLine($"{connection.GetType().Name}.CreateCommand() -> {command.GetType().Name}");
namespace Bookstore.Reporting
{
public record Sale(string Title, int Quantity, decimal Total);
public record ExportedFile(string Name, string Content);
public interface IReportExporter
{
string Extension { get; }
string Export(IReadOnlyList<Sale> sales);
}
public sealed class CsvExporter(string separator) : IReportExporter
{
public string Extension => "csv";
public string Export(IReadOnlyList<Sale> sales) =>
string.Join(Environment.NewLine, sales.Select(s => string.Join(separator, s.Title, s.Quantity, s.Total)));
}
public sealed class MarkdownExporter : IReportExporter
{
public string Extension => "md";
public string Export(IReadOnlyList<Sale> sales) =>
string.Join(Environment.NewLine,
["| Title | Qty | Total |", "|---|---:|---:|", .. sales.Select(s => $"| {s.Title} | {s.Quantity} | {s.Total} |")]);
}
public abstract class ReportJob
{
// The workflow is fixed; the only thing that varies is which exporter gets created
public ExportedFile Run(IReadOnlyList<Sale> sales)
{
IReportExporter exporter = CreateExporter();
return new ExportedFile($"sales-2026-10.{exporter.Extension}", exporter.Export(sales));
}
protected abstract IReportExporter CreateExporter();
}
public sealed class CsvReportJob(string separator) : ReportJob
{
protected override IReportExporter CreateExporter() => new CsvExporter(separator);
}
public sealed class MarkdownReportJob : ReportJob
{
protected override IReportExporter CreateExporter() => new MarkdownExporter();
}
}
--- sales-2026-10.csv
Dune;2;25.98
Emma;1;7.50
--- sales-2026-10.md
| Title | Qty | Total |
|---|---:|---:|
| Dune | 2 | 25.98 |
| Emma | 1 | 7.50 |
--- DbConnection.CreateDbCommand: abstract=True, protected=True
SqliteConnection.CreateCommand() -> SqliteCommand
ReportJob.Run knows how a report is produced (get the exporter, hand it the rows, build the file name from the extension) but knows no concrete exporter. Adding a new format means writing a new subclass without touching Run. And the factory method is now called from the workflow itself, not from the constructor.
The last two lines of the program show one of the oldest examples of this pattern in .NET. In ADO.NET, .NET's data access layer (named after the older ActiveX Data Objects technology), DbConnection.CreateCommand() is public and not virtual; internally it calls the protected abstract CreateDbCommand(). SqliteConnection, SqlConnection and NpgsqlConnection override that method and return their own command type. You talk to DbConnection; the provider decides which command gets created. The reflection output says exactly that: abstract=True, protected=True.
So in 2026, would you really open a subclass for a report job? Usually not. Peter Norvig's observation, mentioned in the previous post, applies here too: in a language with first-class functions, "let a subclass override a method" and "let the constructor take a Func<IReportExporter>" give the same flexibility, and the second needs no inheritance. The inheritance-based form of Factory Method lives on mostly in frameworks today: ADO.NET's provider model, the overridable CreateHost method of ASP.NET Core's WebApplicationFactory<T>, or libraries that expose their own base class to users. In application code, taking the product from outside is usually simpler.
Static Factory Methods: The Relative Not in the GoF
Most of what we call a "factory" in everyday code is not in the GoF at all. TimeSpan.FromSeconds, Guid.CreateVersion7(), Channel.CreateBounded<T>() and ToFrozenDictionary() are static factory methods: no subclass, no override, just a named static method called instead of a constructor. The best argument for the idea is the first item of Effective Java by Joshua Bloch (designer of the Java collections framework): consider static factory methods instead of constructors. Let's see the benefits Bloch lists in the Bookstore and in .NET:
using System.Collections.Frozen;
using System.Threading.Channels;
using Bookstore.Reporting;
// 1) A name: the call says what is being created
ReportPeriod october = ReportPeriod.Month(2026, 10);
ReportPeriod q4 = ReportPeriod.Quarter(2026, 4);
Console.WriteLine($"{october} | {q4}");
Console.WriteLine(ReportPeriod.TryParse("2026-Q5", out _) ? "parsed" : "2026-Q5 rejected");
// 2) Choosing a subtype: the returned type is more specific than the signature, and hidden
Console.WriteLine(Channel.CreateBounded<Sale>(100).GetType().Name);
Console.WriteLine(new Dictionary<string, int> { ["dune"] = 3 }.ToFrozenDictionary().GetType().Name);
// 3) Caching: a new object on every call is not required
Console.WriteLine(ReferenceEquals(Enumerable.Empty<Sale>(), Enumerable.Empty<Sale>()));
Console.WriteLine(ReferenceEquals(Task.FromResult(true), Task.FromResult(true)));
// 4) Meaning through overloads: the integer TimeSpan overloads in .NET 9
Console.WriteLine(TimeSpan.FromMilliseconds(101.832).Ticks);
Console.WriteLine(TimeSpan.FromMilliseconds(101, 832).Ticks);
namespace Bookstore.Reporting
{
public record Sale(string Title, int Quantity, decimal Total);
public sealed record ReportPeriod
{
public DateOnly From { get; }
public DateOnly To { get; }
public string Label { get; }
private ReportPeriod(DateOnly from, DateOnly to, string label) => (From, To, Label) = (from, to, label);
public static ReportPeriod Month(int year, int month)
{
DateOnly from = new(year, month, 1);
return new(from, from.AddMonths(1).AddDays(-1), $"{year}-{month:00}");
}
public static ReportPeriod Quarter(int year, int quarter)
{
ArgumentOutOfRangeException.ThrowIfLessThan(quarter, 1);
ArgumentOutOfRangeException.ThrowIfGreaterThan(quarter, 4);
DateOnly from = new(year, (quarter - 1) * 3 + 1, 1);
return new(from, from.AddMonths(3).AddDays(-1), $"{year}-Q{quarter}");
}
public static bool TryParse(string text, out ReportPeriod? period)
{
period = null;
string[] parts = text.Split('-');
if (parts.Length != 2 || !int.TryParse(parts[0], out int year))
return false;
if (parts[1].StartsWith('Q') && int.TryParse(parts[1][1..], out int q) && q is >= 1 and <= 4)
period = Quarter(year, q);
else if (int.TryParse(parts[1], out int m) && m is >= 1 and <= 12)
period = Month(year, m);
return period is not null;
}
public override string ToString() => $"{Label}: {From:yyyy-MM-dd}..{To:yyyy-MM-dd}";
}
}
2026-10: 2026-10-01..2026-10-31 | 2026-Q4: 2026-10-01..2026-12-31
2026-Q5 rejected
BoundedChannel`1
LengthBucketsFrozenDictionary`1
True
True
1018319
1018320
The first benefit is a name. The signature new ReportPeriod(from, to) cannot express the difference between "October" and "the fourth quarter"; two constructors taking the same parameter types cannot be told apart by overloading either. Month and Quarter state the intent and enforce their own rules. Because the constructor is private, there is no way left to create an invalid period (ending before it starts, or four months long while called a "quarter"). TryParse is the member of the same family that does not throw.
The second benefit is that the returned type can be more specific than the signature, and hidden. Channel.CreateBounded returns Channel<T> in its signature but is really the internal BoundedChannel<T>. ToFrozenDictionary goes further: it looks at the key type and key lengths and picks one of several specialised implementations; for string keys this run got LengthBucketsFrozenDictionary. If Microsoft writes a faster implementation tomorrow, your code benefits without changing. A constructor cannot do this; new always creates exactly the type you wrote.
The third benefit is caching. Enumerable.Empty<T>() returns the same empty instance on every call; Task.FromResult(true) also returned the same completed task on two calls in this run. A constructor always has to create a new object.
The last two lines show a subtlety of static factories. TimeSpan.FromMilliseconds(101.832) takes a double and, because of binary floating-point representation, produces 1,018,319 ticks instead of 1,018,320. That is why .NET 9 added integer overloads to the From* family: FromMilliseconds(101, 832) takes milliseconds and microseconds as integers and gives the right result. Static factories can grow as a family over time, all sharing the same naming convention; that is another of their strengths.
Let's untangle the names. A static factory method is a single method and belongs to the type itself. A simple factory, as in the "A Few Design Patterns in C#" post of the C# OOP (Object-Oriented Programming) series, is a separate class that decides with a switch which type to create; it is not in the GoF either. Factory Method is a decision left to a subclass. Abstract Factory is an object that creates a family of compatible products together, and it is the subject of the next post.
.NET's Factories: ILoggerFactory and IHttpClientFactory
Few types in .NET's own library have Factory in their name, but where they are used matters. Let's see why two of them are factories, that is, why they are not simply created with new:
using Bookstore.Reporting;
using Microsoft.Extensions.Logging;
// A static factory method that produces a factory
using (ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(o => o.SingleLine = true)))
{
ILogger<ReportService> typed = loggerFactory.CreateLogger<ReportService>();
ILogger named = loggerFactory.CreateLogger("Bookstore.Audit");
typed.LogInformation("Exported {Rows} rows", 2);
named.LogWarning("Report {Name} was downloaded twice", "sales-2026-10.csv");
Console.WriteLine($"{typed.GetType().Name} / {named.GetType().Name}");
}
namespace Bookstore.Reporting
{
public sealed class ReportService;
}
Logger`1 / Logger
info: Bookstore.Reporting.ReportService[0] Exported 2 rows
warn: Bookstore.Audit[0] Report sales-2026-10.csv was downloaded twice
LoggerFactory.Create is a static factory method that produces a factory; CreateLogger returns the loggers that factory produces. The factory exists because creation itself is complex: what a logger does depends on the providers registered with the factory (console, debug, OpenTelemetry), the filter rules and the category name. ILogger<ReportService> derives the category name from the type, while CreateLogger("Bookstore.Audit") gives it explicitly. The order of the output is instructive too: the Console.WriteLine line came before the log entries, because the console logger drops messages into a queue and writes them on a background thread; when the using block disposed the factory, the queue was flushed.
The story of IHttpClientFactory is even more instructive. Writing new HttpClient() is easy, but each client opens its own HttpMessageHandler, meaning its own connections; many short-lived clients exhaust sockets. A single static client, on the other hand, cannot see DNS (Domain Name System) changes. IHttpClientFactory applies a policy between the two extremes: CreateClient returns a new, cheap HttpClient on every call but keeps the handlers underneath in a pool and recycles them every two minutes by default. When the answer to "how should this object be created?" requires a policy, the place for that policy is a factory.
Factories with DI: Func<T>, ActivatorUtilities and Keyed Services
The DI (dependency injection) container is already a big factory: it knows which class to create for which interface, with which dependencies. The moment you need a separate factory is usually this: the product's type, or one of its parameters, only becomes known at runtime, from the user's request. The report format and the CSV separator are exactly that; both come from the HTTP (Hypertext Transfer Protocol) request.
using System.Globalization;
using Bookstore.Reporting;
using Microsoft.Extensions.DependencyInjection;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
ServiceCollection services = new();
services.AddSingleton<TimeProvider>(new FixedClock(new DateTimeOffset(2026, 11, 1, 9, 0, 0, TimeSpan.Zero)));
services.AddKeyedTransient<IReportExporter, MarkdownExporter>("md");
services.AddSingleton<IReportExporterFactory, ReportExporterFactory>();
services.AddTransient<ReportService>();
services.AddTransient<NaiveReportService>();
using ServiceProvider provider = services.BuildServiceProvider();
// 1) Microsoft DI does not produce Func<T> on its own
try
{
provider.GetRequiredService<NaiveReportService>();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
// 2) Factory: a runtime argument (separator) + a container dependency (clock)
ReportService reports = provider.GetRequiredService<ReportService>();
Sale[] sales = [new("Dune", 2, 25.98m), new("Emma", 1, 7.50m)];
Console.WriteLine(reports.Export(sales, "csv", separator: ";"));
Console.WriteLine(reports.Export(sales, "md"));
try
{
reports.Export(sales, "xlsx");
}
catch (NotSupportedException ex)
{
Console.WriteLine(ex.Message);
}
namespace Bookstore.Reporting
{
public record Sale(string Title, int Quantity, decimal Total);
public sealed class FixedClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
public interface IReportExporter
{
string Export(IReadOnlyList<Sale> sales);
}
// separator arrives at runtime, clock comes from the container
public sealed class CsvExporter(TimeProvider clock, string separator) : IReportExporter
{
public string Export(IReadOnlyList<Sale> sales) =>
$"# generated {clock.GetUtcNow():yyyy-MM-dd HH:mm}Z" + Environment.NewLine +
string.Join(Environment.NewLine, sales.Select(s => string.Join(separator, s.Title, s.Quantity, s.Total)));
}
public sealed class MarkdownExporter : IReportExporter
{
public string Export(IReadOnlyList<Sale> sales) =>
string.Join(Environment.NewLine, sales.Select(s => $"- {s.Title} x{s.Quantity}: {s.Total}"));
}
public interface IReportExporterFactory
{
IReportExporter Create(string format, string separator = ",");
}
// Belongs to the composition root: the only class that knows the container
public sealed class ReportExporterFactory(IServiceProvider services) : IReportExporterFactory
{
public IReportExporter Create(string format, string separator = ",") => format switch
{
"csv" => ActivatorUtilities.CreateInstance<CsvExporter>(services, separator),
"md" => services.GetRequiredKeyedService<IReportExporter>("md"),
_ => throw new NotSupportedException($"Report format '{format}' is not supported. Use csv or md.")
};
}
public sealed class ReportService(IReportExporterFactory exporters)
{
public string Export(IReadOnlyList<Sale> sales, string format, string separator = ",") =>
exporters.Create(format, separator).Export(sales);
}
public sealed class NaiveReportService(Func<IReportExporter> createExporter)
{
public string Export(IReadOnlyList<Sale> sales) => createExporter().Export(sales);
}
}
Unable to resolve service for type 'System.Func`1[Bookstore.Reporting.IReportExporter]' while attempting to activate 'Bookstore.Reporting.NaiveReportService'.
# generated 2026-11-01 09:00Z
Dune;2;25.98
Emma;1;7.50
- Dune x2: 25.98
- Emma x1: 7.50
Report format 'xlsx' is not supported. Use csv or md.
The first line is a trap people coming from containers like Autofac often fall into. Microsoft's built-in container does not produce Func<T> dependencies on its own; the DI guidelines on Microsoft Learn explicitly list Func<T> support among the missing features that may justify switching to another container. Either you register the delegate yourself (services.AddSingleton<Func<IReportExporter>>(...)) or you define a named factory interface.
ReportExporterFactory takes the second route and combines two separate needs. The CSV exporter gets one dependency from the container (TimeProvider) and one parameter from runtime (the separator). ActivatorUtilities.CreateInstance exists for exactly this: it uses the arguments you pass and resolves the rest from the container. The Markdown exporter is registered with keyed services, introduced in .NET 8; the "md" key selects the implementation directly. We will look at keyed services in detail in the Strategy post, along with other ways to choose between several implementations at runtime.
This factory takes an IServiceProvider, and that deserves a warning. Microsoft's guidelines describe injecting a factory that resolves dependencies from the container at runtime as a variant of service locator. The distinction here is this: ReportService does not know the container; it only knows IReportExporterFactory. The only class that knows the container is the factory's implementation, and it belongs to the composition root, the single place where the application wires up its dependencies. If factory implementations start spreading among the business code, the line has been crossed.
The Factory's Ownership Question: IDisposable Products
Where factories and the container meet there is one more question: who disposes the created object? Imagine an exporter that produces Excel files and holds a temp file or a native resource:
using Bookstore.Reporting;
using Microsoft.Extensions.DependencyInjection;
ServiceCollection services = new();
services.AddTransient<SpreadsheetExporter>();
ServiceProvider provider = services.BuildServiceProvider();
// Resolving a transient IDisposable from the root provider 1000 times
for (int i = 0; i < 1000; i++)
{
SpreadsheetExporter exporter = provider.GetRequiredService<SpreadsheetExporter>();
exporter.Export();
}
Console.WriteLine($"After 1000 exports, disposed: {SpreadsheetExporter.Disposed}");
provider.Dispose();
Console.WriteLine($"After provider.Dispose(), disposed: {SpreadsheetExporter.Disposed}");
// With a factory: the calling code owns the instance
SpreadsheetExporter.Disposed = 0;
using ServiceProvider provider2 = services.BuildServiceProvider();
for (int i = 0; i < 1000; i++)
{
using SpreadsheetExporter exporter = ActivatorUtilities.CreateInstance<SpreadsheetExporter>(provider2);
exporter.Export();
}
Console.WriteLine($"Factory + using, disposed: {SpreadsheetExporter.Disposed}");
namespace Bookstore.Reporting
{
// In reality it might hold a temp file or a native resource
public sealed class SpreadsheetExporter : IDisposable
{
public static int Disposed;
public void Export() { }
public void Dispose() => Disposed++;
}
}
After 1000 exports, disposed: 0
After provider.Dispose(), disposed: 1000
Factory + using, disposed: 1000
The container keeps the transient IDisposable objects it creates in a list so it can dispose them, and disposes them only when their scope ends. The root provider's scope is the application itself; after a thousand exports, a thousand exporters are still in memory and none has been disposed. Microsoft Learn lists this as a memory leak anti-pattern and recommends exactly a factory as the solution: a Create method that builds the object outside the container (directly with a constructor or with ActivatorUtilities) and hands ownership to the caller. In the second loop each exporter was disposed immediately by using. The rule is simple: the caller disposes the IDisposable a factory returns, and never the one the container injects.
Common Pitfalls
Wrapping every new in a factory. If BookFactory.Create(isbn, title) contains nothing but new Book(isbn, title), the reader gains an indirection and gets nothing in return. A factory needs a reason: a decision (which type), a policy (pooling, caching), a rule (a valid period) or information that arrives at runtime.
Calling a virtual method from a constructor. The bug at the top of this post is not specific to factory methods; any virtual or abstract method called from a constructor runs at a moment when the subclass is not ready yet. Turning on CA2214 in your project is cheap insurance.
Turning the factory into a dump of boolean flags. A signature like Create(format, compress: true, encrypt: false, legacy: true) shows the factory is trying to answer several different questions at once. If the flags decide not the product itself but its wrappers, separating them into layers, as we will see in the Decorator post, is cleaner.
Turning the factory into a service locator. A generic factory that takes an IServiceProvider and produces any type through Create<T>() is the container carried everywhere under another name. What a factory produces should be narrow and named: IReportExporter, HttpClient, ILogger.
Silently falling back to a default in an unknown case. A factory that produces CSV when "xlsx" is requested hides an error the user will notice one step later. An exception that names the supported formats is better than a file downloaded in the wrong format.
When Not to Use It
If the product's type is known at compile time and creating it is one line, do not write a factory; new or the container's constructor injection is enough. Choose the inheritance-based form of the GoF Factory Method, a creation hook a subclass overrides, only if you already have a base class and are opening an extension point to its users; within a single application, a Func<T> or a small named factory interface gives the same flexibility without inheritance. Static factory methods, on the other hand, you can use generously: they cost almost nothing and help readability a lot. The one thing to watch is a type with both a public constructor and a static factory that does the same job, which leaves the reader unsure which to use; pick one.
A Small Experiment
★☆☆ Add Year(int year) and Last(int days, DateOnly today) factory methods to ReportPeriod. Extend TryParse to understand the "2026" form as well, and show with output that invalid input is rejected.
★★☆ Rewrite the faulty ReportJob from the start of the post so that it takes a Func<IReportExporter> in its constructor instead of a factory method. Do you still need the subclasses? What happened to the CA2214 warning?
★★★ Add a SpreadsheetExporter (IDisposable) to ReportExporterFactory and update ReportService.Export to dispose the exporter it gets from the factory if it is IDisposable. How will you tell that it must not dispose the Markdown exporter, which comes from the container as a keyed service? Propose two different designs and code one of them.
This post covered ways to create a single product. In the next one, products come as families: creating blob storage, queue and secret clients from the same cloud provider without mixing them, and Abstract Factory through ADO.NET's DbProviderFactory, which has been doing this job for years.