There is no "one size fits all" solution. What makes sense for your particular application will vary. Here are few ideas that may work for you. There is no preference in order nor is any one particular solution necessarily better than the other. Some options can even be combined together.
Option 1
Move as much logic as possible out of your controllers. Controllers are just a way to represent your API over HTTP. By delegating as much of the logic as possible into collaborators, you can likely reduce a lot of duplication.
Ideally, an action method should be less than 10 lines of code. Extension methods, custom results, and so on can help reduce duplication.
Option 2
Define a clear versioning policy; for example N-2
. This can really help clamp down on duplication, but not necessarily eliminate it. Managing duplication across 3 versions is much more manageable if it's unbound.
It should be noted that sharing across versions also comes with some inherent risks (which you might be willing to accept). For example, a change or fix could affect multiple versions and in unexpected or undesirable ways. This is more likely to occur when interleaving multiple versions on a single controller. Some services choose a Copy & Paste approach for new versions to retain the same base implementation, but then allow the implementations to evolve independently. That doesn't mean you can't have shared components, just be careful what you share.
Option 3
Use nullable attributes and ensure your serialization options do not emit null
attributes. This obviously doesn't work if you allow or use explicit null
values.
For example, the age
attribute can be removed using a single model like this:
public class User
{
// other attributes omitted for brevity
public int? Age { get; set; }
}
[HttpGet("user")]
[MapToApiVersion("2.0")]
public async Task<IActionResult> GetUserV2([FromQuery] string id)
{
var user = await _service.GetUser(id);
// if nulls are not emitted, then this effective 'removes' the
// 'age' member using a single model
user.Age = null;
return Ok(user);
}
Option 4
Use an adapter. This could get tedious if you don't have a fixed versioning policy, but is manageable for a limited number of versions. You could also using templating or source generators to render the code for you.
public class User2Adapter
{
private readonly User inner;
public User2Adapter(User user) => inner = user;
public FirstName => inner.FirstName;
public LastName => inner.LastName;
}
[HttpGet("user")]
[MapToApiVersion("2.0")]
public async Task<IActionResult> GetUserV2([FromQuery] string id)
{
return Ok(new User2Adapter(await _service.GetUser(id)));
}
This approach is used for serializing ProblemDetails
using Newtonsoft.Json (see here)
This can also be achieved with anonymous types:
[HttpGet("user")]
[MapToApiVersion("2.0")]
public async Task<IActionResult> GetUserV2([FromQuery] string id)
{
var user = await _service.GetUser(id);
var userV2 = new
{
firstName = user.FirstName,
lastName = user.LastName,
};
return Ok(userV2);
}
Option 5
Use a custom OutputFormatter
. The default implementation in SystemJsonTextOutputFormatter doesn't honor the specified object type unless the supplied object itself is null
. You can change this behavior.
A complete implementation would be a bit verbose, but you can imagine that you might have something like this (abridged):
public class VersionedJsonOutputFormatter : TextOutputFormatter
{
private readonly Dictionary<ApiVersion, Dictionary<Type, Type>> map = new()
{
[new ApiVersion(1.0)] = new()
{
[typeof(User)] = typeof(User),
},
[new ApiVersion(2.0)] = new()
{
[typeof(User)] = typeof(User2),
},
}
public VersionedJsonOutputFormatter(
JsonSerializerOptions jsonSerializerOptions)
{
// TODO: copy SystemJsonTextOutputFormatter implementation
}
public override async Task WriteResponseBodyAsync(
OutputFormatterWriteContext context,
Encoding selectedEncoding)
{
// IMPORTANT: abridged with many assumptions; look at
// SystemJsonTextOutputFormatter implementation
var httpContext = context.HttpContext;
var apiVersion = httpContext.GetRequestedApiVersion();
var objectType = map[apiVersion][context.Object.GetType()];
var ct = httpContext.RequestAborted;
try
{
await JsonSerializer.SerializeAsync(
responseStream,
context.Object,
objectType,
SerializerOptions,
ct);
await responseStream.FlushAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
}
}
}
This is just one approach. There are plenty of variations on how you can change the mapping.
Option 6
This one area where OData (or even EF) really shines. The use of an Entity Data Model (EDM) separates the model over the wire vs the code model. You can have a single, unified code model with a different EDM per API version that controls how that is serialized over the wire. I'm not sure you can yank only the specific bits that you want for EDM and serialization, but if you can, it just might get you what you want with minimal effort. This is approach is certainly useful for APIs outside of the context of OData.
The OData examples for API Versioning show this at work. I've never tried using things in a purely non-OData way, but that doesn't mean it can't be made to work.