Abstract Factory: Creating Families of Compatible Objects Together
Abstract Factory makes invalid combinations impossible by creating products that must fit together (such as blob storage, queue and secret store) from a single factory; the price is that adding a new product changes every factory. Choosing the family through configuration, ADO.NET's DbProviderFactory and CanCreateBatch, and a factory per tenant with keyed services, with real output.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
The Bookstore uploads invoices as PDFs to blob storage and notifies the print worker through a queue. The company has been moving from Azure to AWS (Amazon Web Services) for a while, and configuration has a separate setting for each product. During the migration someone set the queue setting to Aws and left the blob setting alone. Each setting is valid on its own; both passed code review. What happens when the worker receives the message?
The three families of blob storage, queue and secret store (Azure, AWS and local development) are shared by every example in this post. Instead of the real SDKs (Software Development Kits) I use an in-memory simulation; the only detail that matters is that each cloud has its own reference format:
public interface IBlobStore
{
string Upload(string path, byte[] content); // returns a durable reference
byte[] Download(string reference);
}
public interface IMessageQueue
{
void Send(string body);
string? Receive();
}
public interface ISecretStore
{
string Get(string name);
}
// Simulation: in a real project these classes wrap BlobContainerClient and QueueClient from the Azure SDK,
// or AmazonS3Client and AmazonSQSClient from the AWS SDK. Each cloud has its own reference format.
public abstract class SimulatedBlobStore(string scheme) : IBlobStore
{
private static readonly Dictionary<string, byte[]> Objects = [];
public string Upload(string path, byte[] content)
{
string reference = $"{scheme}://{path}";
Objects[reference] = content;
return reference;
}
public byte[] Download(string reference) =>
reference.StartsWith($"{scheme}://", StringComparison.Ordinal) && Objects.TryGetValue(reference, out byte[]? content)
? content
: throw new InvalidOperationException($"{GetType().Name} cannot read '{reference}'.");
}
public abstract class SimulatedQueue : IMessageQueue
{
private readonly Queue<string> _messages = new();
public void Send(string body) => _messages.Enqueue(body);
public string? Receive() => _messages.TryDequeue(out string? body) ? body : null;
}
public sealed class AzureBlobStore() : SimulatedBlobStore("azure-blob");
public sealed class AzureStorageQueue : SimulatedQueue;
public sealed class S3BlobStore() : SimulatedBlobStore("s3");
public sealed class SqsQueue : SimulatedQueue;
public sealed class LocalBlobStore() : SimulatedBlobStore("file");
public sealed class LocalQueue : SimulatedQueue;
public sealed class DictionarySecretStore(string source, Dictionary<string, string> secrets) : ISecretStore
{
public string Get(string name) => secrets.TryGetValue(name, out string? value)
? value
: throw new KeyNotFoundException($"Secret '{name}' not found in {source}.");
}
And the application itself:
using System.Text;
using Bookstore.Cloud;
// Each product is chosen from its own setting; one was set differently by mistake
Dictionary<string, string> config = new()
{
["Cloud:Blob"] = "Azure",
["Cloud:Queue"] = "Aws"
};
IBlobStore blobs = config["Cloud:Blob"] switch
{
"Azure" => new AzureBlobStore(),
"Aws" => new S3BlobStore(),
_ => new LocalBlobStore()
};
IMessageQueue queue = config["Cloud:Queue"] switch
{
"Azure" => new AzureStorageQueue(),
"Aws" => new SqsQueue(),
_ => new LocalQueue()
};
// The invoice PDF is uploaded and a message goes to the print worker
string reference = blobs.Upload("invoices/1042.pdf", Encoding.UTF8.GetBytes("%PDF invoice 1042"));
queue.Send(reference);
// The worker reads from the storage of the cloud it got the message from (a worker listening to SQS runs on AWS)
IBlobStore workerBlobs = new S3BlobStore();
string message = queue.Receive()!;
Console.WriteLine($"Message: {message}");
Console.WriteLine(Encoding.UTF8.GetString(workerBlobs.Download(message)));
Two Correct Settings, One Wrong Family
Output (stack trace shortened):
Message: azure-blob://invoices/1042.pdf
Unhandled exception. System.InvalidOperationException: S3BlobStore cannot read 'azure-blob://invoices/1042.pdf'.
The invoice was uploaded to Azure, and the message went to the queue on AWS. The worker listening to SQS (Simple Queue Service) runs on AWS and can only read S3 (Simple Storage Service); the reference in its hand points to an Azure blob. Each setting carries a correct value, but together they form an invalid combination. Worse, nothing looks wrong in the code that produces it: two switch expressions, two settings, two valid choices. In real life this bug usually shows up in the worker's logs in the first hour after going to production.
The problem is not in the individual products but in how they fit together. Blob storage, queue and secret store form a family; members of the same cloud understand each other's references, credentials and network settings. The GoF (Gang of Four: Gamma, Helm, Johnson and Vlissides, authors of the 1994 Design Patterns book) solves this problem with Abstract Factory. Its intent: provide an interface for creating families of related or dependent objects without specifying their concrete classes. The book's own example is also a family consistency problem: in a user interface toolkit supporting look-and-feel standards such as Motif and Presentation Manager, the scroll bar must not come from one standard and the window from another. The pattern's other name says the same: Kit.
Abstract Factory: Consistency of the Family
The fix is to reduce the questions "which blob store, which queue" to a single question: "which family". Each family is a factory that creates all of its own products:
namespace Bookstore.Cloud;
// Abstract factory: creates all products of one family together
public interface ICloudFactory
{
string Name { get; }
IBlobStore CreateBlobStore();
IMessageQueue CreateQueue();
ISecretStore CreateSecretStore();
}
public sealed class AzureCloudFactory : ICloudFactory
{
public string Name => "Azure";
public IBlobStore CreateBlobStore() => new AzureBlobStore();
public IMessageQueue CreateQueue() => new AzureStorageQueue();
public ISecretStore CreateSecretStore() => new DictionarySecretStore("Key Vault", new() { ["print-api-key"] = "kv-123" });
}
public sealed class AwsCloudFactory : ICloudFactory
{
public string Name => "Aws";
public IBlobStore CreateBlobStore() => new S3BlobStore();
public IMessageQueue CreateQueue() => new SqsQueue();
public ISecretStore CreateSecretStore() => new DictionarySecretStore("Secrets Manager", new() { ["print-api-key"] = "sm-456" });
}
public sealed class LocalCloudFactory : ICloudFactory
{
public string Name => "Local";
public IBlobStore CreateBlobStore() => new LocalBlobStore();
public IMessageQueue CreateQueue() => new LocalQueue();
public ISecretStore CreateSecretStore() => new DictionarySecretStore("environment", new() { ["print-api-key"] = "dev-key" });
}
In GoF role names: ICloudFactory is the abstract factory; AzureCloudFactory, AwsCloudFactory and LocalCloudFactory are concrete factories; IBlobStore, IMessageQueue and ISecretStore are abstract products, and the classes in the simulation are concrete products. Notice that each method of each concrete factory is really a factory method; the GoF itself says Abstract Factories are usually implemented with factory methods. The difference between the two patterns is in the role: Factory Method leaves the decision about a single product to a subclass, while Abstract Factory gathers a whole product family into one object.
The GoF sums up the consequences of this pattern in four points. It isolates concrete classes from the client: application code never sees the name S3BlobStore. It makes exchanging product families easy: the whole family changes by changing a single factory instance. It promotes consistency among products: products from the same factory must belong to the same family. The fourth point is a warning: adding a new kind of product to the family is hard. We will come back to that last one shortly.
Leaving the Choice to Configuration
Choosing the factory is a decision made once at application startup, and its natural home is the DI (dependency injection) registration. The products are registered in the container by deriving them from the factory; application code can inject IBlobStore and IMessageQueue directly without ever seeing the factory:
using System.Text;
using Bookstore.Cloud;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Cloud:Provider"] = "Local" })
.AddCommandLine(args)
.Build();
ServiceCollection services = new();
// A single decision point: the family is chosen once, the products come from it
services.AddSingleton<ICloudFactory>(config["Cloud:Provider"] switch
{
"Azure" => new AzureCloudFactory(),
"Aws" => new AwsCloudFactory(),
"Local" => new LocalCloudFactory(),
null => throw new InvalidOperationException("Cloud:Provider is missing."),
string other => throw new InvalidOperationException($"Unknown cloud provider '{other}'.")
});
services.AddSingleton(sp => sp.GetRequiredService<ICloudFactory>().CreateBlobStore());
services.AddSingleton(sp => sp.GetRequiredService<ICloudFactory>().CreateQueue());
services.AddSingleton(sp => sp.GetRequiredService<ICloudFactory>().CreateSecretStore());
using ServiceProvider provider = services.BuildServiceProvider();
IBlobStore blobs = provider.GetRequiredService<IBlobStore>();
IMessageQueue queue = provider.GetRequiredService<IMessageQueue>();
ISecretStore secrets = provider.GetRequiredService<ISecretStore>();
queue.Send(blobs.Upload("invoices/1042.pdf", Encoding.UTF8.GetBytes("%PDF invoice 1042")));
string message = queue.Receive()!;
Console.WriteLine($"Family: {provider.GetRequiredService<ICloudFactory>().Name}");
Console.WriteLine($"Message: {message}");
Console.WriteLine($"Content: {Encoding.UTF8.GetString(blobs.Download(message))}");
Console.WriteLine($"Api key: {secrets.Get("print-api-key")}");
I ran the same compiled application with three different settings:
$ dotnet run
Family: Local
Message: file://invoices/1042.pdf
Content: %PDF invoice 1042
Api key: dev-key
$ dotnet run -- --Cloud:Provider=Aws
Family: Aws
Message: s3://invoices/1042.pdf
Content: %PDF invoice 1042
Api key: sm-456
$ dotnet run -- --Cloud:Provider=Gcp
Unhandled exception. System.InvalidOperationException: Unknown cloud provider 'Gcp'.
Configuration now has a single setting per family instead of one per product: Cloud:Provider. A half migration is impossible; either the whole family is on AWS or none of it is. An unknown value does not silently fall back to the local family either; the application stops at startup with a clear message. In a real ASP.NET Core application the same registration goes through builder.Configuration and changes per environment via appsettings.Production.json; the Configuration and Options Pattern post in the ASP.NET Core Web API (Application Programming Interface) series covers these layers in detail.
I registered the products as singletons because the clients of the real SDKs (such as BlobContainerClient and AmazonSQSClient) are designed to be thread-safe and are recommended to be used as a single instance for the life of the application. Concrete factories themselves are usually single instances too; that is why the GoF says Abstract Factory often comes with Singleton. In the DI world this means registering the factory with AddSingleton; as we saw in the previous post, there is no need for the classic Singleton.
ADO.NET's DbProviderFactory: The Real Abstract Factory in .NET
The oldest and purest example of this pattern in .NET's own library is in ADO.NET (.NET's data access layer, named after the older ActiveX Data Objects technology). DbProviderFactory creates the whole product family of a database provider: connection, command, parameter, data adapter, command builder and, since .NET 6, batch. Let's put the SQLite and SQL (Structured Query Language) Server providers side by side:
using System.Data.Common;
using Microsoft.Data.SqlClient;
using Microsoft.Data.Sqlite;
// Providers are registered by invariant name at startup
DbProviderFactories.RegisterFactory("Microsoft.Data.Sqlite", SqliteFactory.Instance);
DbProviderFactories.RegisterFactory("Microsoft.Data.SqlClient", SqlClientFactory.Instance);
foreach (string name in DbProviderFactories.GetProviderInvariantNames())
{
DbProviderFactory factory = DbProviderFactories.GetFactory(name);
Console.WriteLine($"{name}:");
Console.WriteLine($" {factory.CreateConnection()!.GetType().Name}, {factory.CreateCommand()!.GetType().Name}, {factory.CreateParameter()!.GetType().Name}");
Console.WriteLine($" CanCreateDataAdapter={factory.CanCreateDataAdapter}, CanCreateCommandBuilder={factory.CanCreateCommandBuilder}, CanCreateBatch={factory.CanCreateBatch}");
}
// Provider-agnostic code: it only knows DbProviderFactory
Console.WriteLine($"Books: {CountBooks(DbProviderFactories.GetFactory("Microsoft.Data.Sqlite"), "Data Source=:memory:")}");
try
{
SqliteFactory.Instance.CreateBatch();
}
catch (NotSupportedException ex)
{
Console.WriteLine($"SqliteFactory.CreateBatch: {ex.GetType().Name}");
}
static long CountBooks(DbProviderFactory factory, string connectionString)
{
using DbConnection connection = factory.CreateConnection()!;
connection.ConnectionString = connectionString;
connection.Open();
using DbCommand create = connection.CreateCommand();
create.CommandText = "CREATE TABLE Books (Isbn TEXT PRIMARY KEY, Title TEXT NOT NULL)";
create.ExecuteNonQuery();
foreach ((string isbn, string title) in new[] { ("978-0441013593", "Dune"), ("978-0141439518", "Emma") })
{
using DbCommand insert = connection.CreateCommand();
insert.CommandText = "INSERT INTO Books (Isbn, Title) VALUES (@isbn, @title)";
DbParameter p1 = factory.CreateParameter()!;
p1.ParameterName = "@isbn";
p1.Value = isbn;
DbParameter p2 = factory.CreateParameter()!;
p2.ParameterName = "@title";
p2.Value = title;
insert.Parameters.Add(p1);
insert.Parameters.Add(p2);
insert.ExecuteNonQuery();
}
using DbCommand count = connection.CreateCommand();
count.CommandText = "SELECT COUNT(*) FROM Books";
return (long)count.ExecuteScalar()!;
}
Microsoft.Data.SqlClient:
SqlConnection, SqlCommand, SqlParameter
CanCreateDataAdapter=True, CanCreateCommandBuilder=True, CanCreateBatch=True
Microsoft.Data.Sqlite:
SqliteConnection, SqliteCommand, SqliteParameter
CanCreateDataAdapter=False, CanCreateCommandBuilder=False, CanCreateBatch=False
Books: 2
SqliteFactory.CreateBatch: NotSupportedException
CountBooks does not know SQLite; it only talks to DbProviderFactory, DbConnection, DbCommand and DbParameter. Because the factory comes from the SQLite family, the connection, command and parameter all come from the same family and are compatible with each other. Give it SQL Server's factory and the same code (with unchanged SQL) would run with SqlConnection and SqlCommand. Libraries that want to support several providers (reporting tools, migration tools) take this route in real applications. Dapper's extension methods, which we saw in the Dapper series, also work on top of the same ADO.NET abstraction (IDbConnection).
The really interesting part of the output is the second and fourth lines: the SQL Server family can create data adapters, command builders and batches; the SQLite family cannot. And calling SqliteFactory.CreateBatch() threw NotSupportedException. This is the GoF's fourth warning, live in .NET.
Adding a Product to the Family: The GoF's Own Warning
Abstract Factory makes adding families cheap but adding products expensive. Suppose we want to add a cache to the Bookstore's Azure family, and do it the most natural way, by adding a method to the interface:
public interface ICloudFactory
{
string Name { get; }
IBlobStore CreateBlobStore();
IMessageQueue CreateQueue();
ISecretStore CreateSecretStore();
ICache CreateCache(); // a new product in the family
}
public interface ICache
{
void Set(string key, string value);
}
Even though we added CreateCache to AzureCloudFactory, the build says:
Factories.cs(34,39): error CS0535: 'AwsCloudFactory' does not implement interface member 'ICloudFactory.CreateCache()'
Factories.cs(42,41): error CS0535: 'LocalCloudFactory' does not implement interface member 'ICloudFactory.CreateCache()'
A new product changes the abstract factory's interface and therefore every concrete factory that implements it. Your code has three factories and all three are in your hands; the job takes an afternoon. In ADO.NET, though, the concrete factories live in providers written by other companies (Npgsql, MySqlConnector, Oracle). Had Microsoft added an abstract method for batching in .NET 6, every provider in the world would have stopped compiling. Instead it added a virtual CreateBatch() (defaulting to NotSupportedException) and a virtual CanCreateBatch (defaulting to false). Providers overrode them as they added support in their own releases; SQL Server's did, SQLite's has not yet. We can take the same route in the Bookstore:
namespace Bookstore.Cloud;
public interface ICache
{
void Set(string key, string value);
}
public sealed class AzureCache : ICache
{
public void Set(string key, string value) { }
}
// The route taken by ADO.NET's DbProviderFactory: a new product arrives as virtual + a Can* property
public abstract class CloudFactory
{
public abstract string Name { get; }
public abstract IBlobStore CreateBlobStore();
public abstract IMessageQueue CreateQueue();
public virtual bool CanCreateCache => false;
public virtual ICache CreateCache() => throw new NotSupportedException($"{Name} family has no cache yet.");
}
public sealed class AzureCloudFactory : CloudFactory
{
public override string Name => "Azure";
public override IBlobStore CreateBlobStore() => new AzureBlobStore();
public override IMessageQueue CreateQueue() => new AzureStorageQueue();
public override bool CanCreateCache => true;
public override ICache CreateCache() => new AzureCache();
}
public sealed class AwsCloudFactory : CloudFactory
{
public override string Name => "Aws";
public override IBlobStore CreateBlobStore() => new S3BlobStore();
public override IMessageQueue CreateQueue() => new SqsQueue();
}
A third option in C# is a method with a default implementation in the interface (a default interface method, introduced in C# 8): ICache CreateCache() => throw new NotSupportedException();. Its effect is similar to the virtual method in an abstract base class, with one difference: the default implementation can only be reached through the interface type, not through the concrete class. All three routes have the same price: instead of the compiler catching "this family has no cache" at compile time, you get a runtime check (CanCreateCache) or an exception. The consistency guarantee weakens a little; in exchange, adding a product to the family does not break the whole ecosystem. Which one you choose depends on who owns the concrete factories.
Two Families at Once: A Factory per Tenant with Keyed Services
In the examples so far, one family was chosen at startup. Where Abstract Factory really shines is when several families live together in the same process. Imagine the Bookstore hosts franchise stores and each store (tenant) can choose its own cloud. Keyed services, introduced in .NET 8, let you register factories by name and resolve them by name at runtime:
using System.Text;
using Bookstore.Cloud;
using Microsoft.Extensions.DependencyInjection;
ServiceCollection services = new();
services.AddKeyedSingleton<CloudFactory, AzureCloudFactory>("Azure");
services.AddKeyedSingleton<CloudFactory, AwsCloudFactory>("Aws");
services.AddSingleton<InvoicePublisher>();
using ServiceProvider provider = services.BuildServiceProvider();
InvoicePublisher publisher = provider.GetRequiredService<InvoicePublisher>();
// Two families in the same application: each tenant on its own cloud
foreach (Tenant tenant in new Tenant[] { new("north-books", "Azure"), new("east-books", "Aws") })
Console.WriteLine(publisher.Publish(tenant, 1042));
public sealed record Tenant(string Id, string Cloud);
public sealed class InvoicePublisher(IServiceProvider services)
{
public string Publish(Tenant tenant, int invoiceNo)
{
CloudFactory cloud = services.GetRequiredKeyedService<CloudFactory>(tenant.Cloud);
IBlobStore blobs = cloud.CreateBlobStore();
IMessageQueue queue = cloud.CreateQueue();
queue.Send(blobs.Upload($"{tenant.Id}/invoices/{invoiceNo}.pdf", Encoding.UTF8.GetBytes("%PDF")));
if (cloud.CanCreateCache)
cloud.CreateCache().Set($"invoice:{invoiceNo}", "published");
return $"{tenant.Id,-12} {queue.Receive()} (cache: {cloud.CanCreateCache})";
}
}
north-books azure-blob://north-books/invoices/1042.pdf (cache: True)
east-books s3://east-books/invoices/1042.pdf (cache: False)
Both tenants use the same InvoicePublisher, but each gets the products of its own family; one tenant's blob cannot end up in the other's queue by mistake. The CanCreateCache check shows the price of the decision from the previous section: the AWS family has no cache, and the code asks about it at runtime.
InvoicePublisher taking an IServiceProvider is a deliberate compromise. We have to choose the factory per tenant at runtime, and we can do that either with the container or with an IReadOnlyDictionary<string, CloudFactory> built in the composition root. In a large application I would prefer the latter, or a small ICloudFactoryResolver interface that knows the container; the service locator warning from the previous post applies here too.
Factory Method, Abstract Factory and DI: Which One When
The three concepts are often confused because they are all about "moving creation somewhere else". They differ in the question they answer:
| Tool | Question it answers | Point of variation | .NET example |
|---|---|---|---|
| Static factory method | How, and under what name, should this type be created? | The type itself | Channel.CreateBounded |
| Factory Method | The workflow is the same; who chooses the product? | A subclass | DbConnection.CreateDbCommand |
| Abstract Factory | Who creates a family of compatible products? | A factory object | DbProviderFactory |
| DI container | What is this interface's implementation and lifetime? | Registration | AddSingleton, AddKeyedSingleton |
In practice these tools are not rivals; they work together: the Abstract Factory's methods are factory methods, the abstract factory is a singleton registered in the container, and its products are injected through the container. What matters is knowing which question is answered at each level.
Common Pitfalls
Choosing products from separate settings. The bug at the start of this post is exactly that. If the products form a family, configuration should have a single family setting too. Per-product settings make invalid combinations possible, and no compiler catches them.
Writing an abstract factory for a single product. An ICloudFactory containing only CreateBlobStore() produces a single product, not a family; calling it Abstract Factory only adds indirection. To choose a single product, a DI registration or the factory from the previous post is enough.
Writing a "flexible" factory with a generic Create<T>(). The GoF's implementation section mentions a single Make(kind) method that takes the product kind as a parameter as an "extensible factory", and states its price: type safety goes, and the client has to cast the returned object. In C# this usually turns into a service locator carrying the container under another name.
Leaving concrete products constructible outside the family. If new S3BlobStore() can be written anywhere in application code, the factory's consistency guarantee remains a request. Making the concrete classes internal and exposing only the factories and interfaces as public hands the guarantee to the compiler.
Mistaking the simulation for reality. The families in this post run in memory and do not reflect all the differences between real clouds (consistency model, message size limits, retry behaviour). In a real migration those differences are where the abstraction leaks, and they call for contract tests.
When Not to Use It
Most applications use a single family in production and never change it. Setting up an ICloudFactory today because "maybe we will move to AWS one day" is building a wall without knowing which way change will come; we discussed the price of that in the DRY (Don't Repeat Yourself), KISS (Keep It Simple, Stupid) and YAGNI (You Aren't Gonna Need It) post of the SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series. If you will stay with one family, register the products directly in the container; if you have a fake family for tests, do that in the test project's registration. Save Abstract Factory for places where families genuinely coexist (multi-cloud tenants, a library supporting several databases at once) or where breaking family consistency is expensive. And if you know the product family will grow often, consider ADO.NET's route, virtual methods and Can* properties, from the start instead of an interface.
A Small Experiment
★☆☆ Add a CreateSecretStore test to LocalCloudFactory: print the exception message you get when asking for a secret other than "print-api-key". Does the message tell you which source of which family was searched?
★★☆ Move the concrete product classes into a separate class library, make them internal and leave only the factories public. Try writing new S3BlobStore() in the application project and note the error code the compiler gives.
★★★ Extend the DbProviderFactory example to connect to a SQL Server running in Docker: run the same CountBooks function with both providers. Which line of SQL did you have to change? Why does Abstract Factory not solve differences between SQL dialects?
The next post moves to perhaps the most misunderstood creational pattern: Builder. We will see why the GoF's Builder with a director and the fluent builder are not the same thing, how far required and init go in solving the telescoping constructor problem, and why WebApplicationBuilder locks itself after Build().