Introduction
Since I went to be a freelancer some months before, I am lucky enough to participate in a lot of interesting projects. The most recent one is about creating surveys. What’s the topic?
- A catalog of questions shall be configured.
- Questions can be hierarchical, rely on parent. Just one level.
- Questions are defined with types. checkboxex, dropdowns, just text, numbers, percents, …
- Based on the questions, it shall be possible to take surveys.
- When a questionaire is going to be published, it is not allowed to change it anymore
- The current questionaire can be edited anytime.
- At least one time a year a questionaire is published
Just a rough overview to point to the actual implementation strategy.
TL;DR
- EF Core uses default interface methods
- Methods are often used to guarantee backward compatability
- A multi-tenant like solution following the samples lead to a stack overflow exception – which is hard to google these days with this popular site in place
- Compiler and IDE do not give any hint of wrong implementations
Technology stack
The technology stack is .net core 6.0, ef core 6.0. The application is completely hosted in Azure. Overall architecture looks like the following.
All services are deployed via Terraform, Azure DevOps pipelines are heavily used. All services with relevant data are hidden behind private endpoints.
Basic Implementation idea of questionaires
So me and the team sit together and talked about possible implementations. Questionaires shall be read-only when being published, but always editable. How to accomplish that requirement?
Certainly it is a copy process. Current questionaire must be duplicated somehow to separate it from the current and editable one. There are a relations to the questions table, about twenty tables. All information needs to be fixed for the published one. This all sounds like an approach that is pretty popular in tenant-based systems.
- Put an additonal column in every table to distinguish between.
This is quite common solution as it is quite straight forward. Every table is going to be tenant-aware. And all queries against the database need to handle this. Luckily EF Core comes with a feature that is called Global query filters. Global query filters are LINQ query predicates applied to Entity Types in the metadata model. A query predicate is a boolean expression typically passed to the LINQ where query operator. EF Core applies such filters automatically to any LINQ queries involving those Entity Types. EF Core also applies them to Entity Types, referenced indirectly through use of Include or navigation property.
- Create a schema per questonaire
Multiplying the schema is not used that often. It is a valid option to separate the data completely but not going the most intensive way of having multiple databases. EF Core allows to switch DB Contexts and even has a sample in their docs.
- Create a database per questionair
This is the maximal version of separation. In this variant it is very unlikely that data from one tenant is being read from another. EF core does not come with an own sample, but this implementation is a pretty stable one.
The second option is going to be chosen. These are the reasons:
- The implementation should not be affected at all by separation needs. In best case it doesn’t even know.
- Performance should not be affected.
- There shouldn’t be more than one database in place. This would easily lead to cost explosion which is not feasible for the size of project.
- Schemas allow for complete separation but as being in same database, communication between schemas is easy.
Implementation
Implementation of schema-aware DbContexts is pretty straight forward.
EF Core allows to intercept how DBContexts are cached. This is going to be done with a IModelCacheKeyFactory implementation.
public class SchemaAwareModelCacheKeyFactory : IModelCacheKeyFactory
{
public object Create(DbContext context)
=> new SchemaAwareModelCacheKey(context);
public object Create(DbContext context, bool designTime)
=> Create(context);
}
This implementation needs an ModelCacheKey implementation that allows for comparison via equals/ hashcode implementation.
internal class SchemaAwareModelCacheKey : ModelCacheKey
{
private readonly string _schema;
private readonly Type _dbContextType;
private readonly bool _designTime;
public string Schema => _schema;
public Type DbContextType => _dbContextType;
public bool DesignTime => _designTime;
public SchemaAwareModelCacheKey(DbContext context)
: base(context)
{
_schema = (context as ApplicationDbContext)?.Schema;
_dbContextType = context.GetType();
_designTime = false;
}
public SchemaAwareModelCacheKey(DbContext context, bool designTime)
: base(context, designTime)
{
_schema = (context as ApplicationDbContext)?.Schema;
_dbContextType = context.GetType();
_designTime = designTime;
}
protected virtual bool Equals(SchemaAwareModelCacheKey other)
{
return _dbContextType == other.DbContextType &&
_designTime == other.DesignTime &&
_schema == other.Schema;
}
public override bool Equals(object obj)
=> (obj is SchemaAwareModelCacheKey otherAsKey) && Equals(otherAsKey);
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(_dbContextType);
hash.Add(_designTime);
hash.Add(_schema);
return hash.ToHashCode();
}
}
Having this in place, it is necessary to provide the schema to the DbContext. I decided for a header definition to pass the schema information from the application to backend, which is implemented as a middleware.
public class SchemaAwareDbContextMiddleware
{
private readonly RequestDelegate _next;
private const string SCHEMA_NAME = "x-db-schema";
public SchemaAwareDbContextMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext httpContext, IDbContextSchema dbContextSchema)
{
IHeaderDictionary headers = httpContext.Request.Headers;
var schema = httpContext.Request.Headers[SCHEMA_NAME].ToString();
dbContextSchema.SetSchema(schema);
await _next.Invoke(httpContext);
}
}
The IDbContextSchema implementation is used to pass information from controllers to db context as well as handling schema information within EF Core.
public interface IDbContextSchema
{
string Schema { get; }
void SetSchema(string schema);
}
ApplicationDbContextSchema just implements IDbContextSchema and makes it accessible for middleware as well as for DbContext.
public class ApplicationDbContextSchema : IDbContextSchema
{
public ApplicationDbContextSchema(string schema)
{
Schema = schema;
}
public string Schema { get; private set; }
public void SetSchema(string schema)
{
Schema = schema;
}
}
The ApplicationDbContext needs to get the IDbContextSchema injected to initialize the schema.
public class ApplicationDbContext : DbContext, IDbContextSchema
{
public const string DEFAULT_SCHEMA = "default";
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options,
IDbContextSchema schema = null) : base(options)
{
if (schema != null && !string.IsNullOrWhiteSpace(schema.Schema))
{
Schema = schema.Schema;
}
else
{
Schema = DEFAULT_SCHEMA;
}
ChangeTracker.Tracked += OnEntityTracked;
ChangeTracker.StateChanged += OnEntityStateChanged;
}
public string Schema { get; }
}
All of this stuff was implemented pretty fast due to good samples out in the wild. Happy to hit f5 and see it working.
Stack Overflow exception, that’s some time ago
The last years I mostly read things on stack overflow instead of getting stack overflow exceptions. What happened? It looked like this.
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:49190
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5255
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path:
Stack overflow.
Repeat 15815 times:
--------------------------------
at Microsoft.EntityFrameworkCore.Infrastructure.IModelCacheKeyFactory.Create(Microsoft.EntityFrameworkCore.DbContext) at Microsoft.EntityFrameworkCore.Infrastructure.IModelCacheKeyFactory.Create(Microsoft.EntityFrameworkCore.DbContext, Boolean)
--------------------------------
at Microsoft.EntityFrameworkCore.Infrastructure.ModelSource.GetModel(Microsoft.EntityFrameworkCore.DbContext, Microsoft.EntityFrameworkCore.ModelCreationDependencies, Boolean)
at Microsoft.EntityFrameworkCore.Internal.DbContextServices.CreateModel(Boolean)
at Microsoft.EntityFrameworkCore.Internal.DbContextServices.get_Model()
at Microsoft.EntityFrameworkCore.Infrastructure.EntityFrameworkServicesBuilder+<>c.<TryAddCoreServices>b__8_4(System.IServiceProvider)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitFactory(Microsoft.Extensions.DependencyInjection.ServiceLookup.FactoryCallSite, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceCallSite, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext, Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverLock)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceCallSite, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2[[Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext, Microsoft.Extensions.DependencyInjection, Version=6.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60],[System.__Canon, System.Private.CoreLib, Version=6.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]].VisitCallSite(Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceCallSite, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(Microsoft.Extensions.DependencyInjection.ServiceLookup.ConstructorCallSite, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceCallSite, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext, Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverLock)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceCallSite, Microsoft.Extensions.DependencyInjection.ServiceLookup.RuntimeResolverContext)
Actually I was pretty happy which the implementation. I already had the DevOps part in place, automatically creating idempotent scripts from EF Core and apply them automatically for multiple schemas. Code was structured in a maitainable and understandable way. And then I tried it out, application crashed and I had these questions marks above my head.
What happened? Where is this recursion from?
I started to search. Configuration, methods I changed recently. Debugged. Had a look onto samples. Didn’t see the difference. Had a look onto the EF Core sources. This is actually the interface implementation of IModelCacheKeyFactory.
namespace Microsoft.EntityFrameworkCore.Infrastructure
{
//
// Summary:
// Creates keys that uniquely identifies the model for a given context. This is
// used to store and lookup a cached model for a given context.
// The service lifetime is Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton.
// This means a single instance is used by many Microsoft.EntityFrameworkCore.DbContext
// instances. The implementation must be thread-safe. This service cannot depend
// on services registered as Microsoft.Extensions.DependencyInjection.ServiceLifetime.Scoped.
//
// Remarks:
// See EF Core model caching for more information.
public interface IModelCacheKeyFactory
{
//
// Summary:
// Gets the model cache key for a given context.
//
// Parameters:
// context:
// The context to get the model cache key for.
//
// Returns:
// The created key.
[Obsolete("Use the overload with most parameters")]
object Create(DbContext context)
{
return Create(context, designTime: true);
}
//
// Summary:
// Gets the model cache key for a given context.
//
// Parameters:
// context:
// The context to get the model cache key for.
//
// designTime:
// Whether the model should contain design-time configuration.
//
// Returns:
// The created key.
object Create(DbContext context, bool designTime)
{
return Create(context);
}
}
}
I was really surprised by the implementation within the interface. This feature of C# 8.0 didn’t hit me at all. I was confused as I did’t expect it.
But wait. Have a closer look onto the implementation of this interface. Both methods call the other. This looks like a perfect reason for a stack overflow exception. Why did they do it?
Short history plus why does it happen?
To understand why EF Core team implemented it like this is pretty easy. The second method with two parameters supported designTime was not available in the first version of the interface. To not break backward compatibility they decided to just implement it. I just implemented one interface and thought, there is only one method.
I didn’t realize that I had an interface with two methods and just implemented one. Default interface method magic.
This is a feature, for sure. Less work, less thoughts. But all this doesn’t explain why it didn’t take my implementation at all. I ensured that the actual “most” important line is available. But the code didn’t hit my actual implementation.
As it is always, somewhen in time there is this relaxing moment. Before hitting f5 it is already clear that this is solution. And then it works fine. What have been the differences?
Here is the implementation that works like a charm:
That was the difference. I used ApplicationDbContext, my inheritation of DbContext instead of DbContext.
Conclusion
Implementation of an interface with default interface methods falls back to interface implementation when any type does not fit. Even derived ones.
I actually didn’t have the idea of doing anything wrong. ApplicationDbContext derives from DbContext which actually should work. The compiler doesn’t tell anything about issues. Explicit implementation of this type of interface is not possible as not allowed by design. Due to the implementation of the interface it is also not possible to override anything.
This kind of implementation is a pretty well hidden gem, a good reason to search a long time.
Surely these default interface methods are a good idea from various points of view:
- backward compatability
- avoiding abstract class implementations that have a stronger contract than interface when being distributed
- less code to write for consumers of the interface
- this feature enables C# to interoperate with APIs targeting Android (Java) and iOs (Swift), which support similar features.
- adding default interface implementations provides the elements of the “traits” language feature (https://en.wikipedia.org/wiki/Trait_(computer_programming)).
- inheritation from multiple interfaces is possible, while an abstract class is only single inheritance. I am pretty sure, I didn’t want that feature before, actually.
- There is co- and contravariance on interfaces and not on classes in C#
- …
And the drawbacks?
Actually I do like the idea of a clear contract that is nothing more than this. Default interface methods add some levels of complexity, esp. when this feature is not well known by every developer. Guess this is going to be solved over time. Hopefully C# does not get too feature crowded. Also the new nullable functionality leads to a lot of noise. This here does it as well.
What’s your experience with default interface methods?