Command Query Separation (CQS) for ASP.NET Core and Azure Functions
- Build services and functions that separate the responsibility of commands and queries
- Focus on implementing the handlers for commands and queries
- Make use of ASP.NET Core and Azure Functions to create APIs with CQS
Commands and Queries:
ASP.NET Core:
Azure Functions:
Testing:
Inspired by:
- https://cuttingedge.it/blogs/steven/pivot/entry.php?id=91
- https://cuttingedge.it/blogs/steven/pivot/entry.php?id=92
Command Query Separation in a nutshell:
- Commands
- Writes (Create, Update, Delete) data
- Queries
- Reads and returns data
Commands: Change the state of a system but do not return a value.
Download from NuGet: https://www.nuget.org/packages/CommandQuery/
Example code: CommandQuery.Sample
Create a Command
and CommandHandler
:
using System.Threading.Tasks;
namespace CommandQuery.Sample.Commands
{
public class FooCommand : ICommand
{
public string Value { get; set; }
}
public class FooCommandHandler : ICommandHandler<FooCommand>
{
public async Task HandleAsync(FooCommand command)
{
// TODO: do some real command stuff
await Task.Delay(10);
}
}
}
Commands implements the marker interface ICommand
and command handlers implements ICommandHandler<in TCommand>
.
Queries: Return a result and do not change the observable state of the system (are free of side effects).
Download from NuGet: https://www.nuget.org/packages/CommandQuery/
Example code: CommandQuery.Sample
Create a Query
, QueryHandler
and Result
:
using System;
using System.Threading.Tasks;
namespace CommandQuery.Sample.Queries
{
public class Bar
{
public int Id { get; set; }
public string Value { get; set; }
}
public class BarQuery : IQuery<Bar>
{
public int Id { get; set; }
}
public class BarQueryHandler : IQueryHandler<BarQuery, Bar>
{
public async Task<Bar> HandleAsync(BarQuery query)
{
var result = new Bar { Id = query.Id, Value = DateTime.Now.ToString("F") }; // TODO: do some real query stuff
return await Task.FromResult(result);
}
}
}
Queries implements the marker interface IQuery<TResult>
and query handlers implements IQueryHandler<in TQuery, TResult>
.
- Provides generic actions for handling the execution of commands and queries
- Provides an API based on HTTP
POST
Download from NuGet: https://www.nuget.org/packages/CommandQuery.AspNetCore/
Example code: CommandQuery.Sample.AspNetCore
- Create a new ASP.NET Core 2.0 project
- Install the
CommandQuery.AspNetCore
package from NuGetPM>
Install-Package CommandQuery.AspNetCore
- Create controllers
- Inherit from
BaseCommandController
andBaseQueryController
- Inherit from
- Create commands and command handlers
- Implement
ICommand
andICommandHandler<in TCommand>
- Implement
- Create queries and query handlers
- Implement
IQuery<TResult>
andIQueryHandler<in TQuery, TResult>
- Implement
- Add the handlers to the dependency injection container
services.AddCommands(typeof(Startup).Assembly);
services.AddQueries(typeof(Startup).Assembly);
Add a CommandController
:
using CommandQuery.AspNetCore;
using Microsoft.AspNetCore.Mvc;
namespace CommandQuery.Sample.AspNetCore.Controllers
{
[Route("api/[controller]")]
public class CommandController : BaseCommandController
{
public CommandController(ICommandProcessor commandProcessor) : base(commandProcessor)
{
}
}
}
Inherit from BaseCommandController
and pass the ICommandProcessor
to the base constructor.
The action method from the base class will handle all commands:
[HttpPost]
[Route("{commandName}")]
public async Task<object> Handle(string commandName, [FromBody] Newtonsoft.Json.Linq.JObject json)
- The action is requested via HTTP
POST
with the Content-Typeapplication/json
in the header. - The name of the command is the slug of the URL.
- The command itself is provided as JSON in the body.
- If the command succeeds; the response is empty with the HTTP status code
200
. - If the command fails; the response is an error message with the HTTP status code
400
or500
.
Example of a command request via curl:
curl -X POST -d "{'Value':'Foo'}" http://localhost:57857/api/command/FooCommand --header "Content-Type:application/json"
Configure services in Startup.cs
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc();
// Add commands.
services.AddCommands(typeof(Startup).Assembly);
}
The extension method AddCommands
will add all command handlers in the given assemblies to the dependency injection container.
You can pass in a params
array of Assembly
arguments if your command handlers are located in different projects.
If you only have one project you can use typeof(Startup).Assembly
as a single argument.
Add a QueryController
:
using CommandQuery.AspNetCore;
using Microsoft.AspNetCore.Mvc;
namespace CommandQuery.Sample.AspNetCore.Controllers
{
[Route("api/[controller]")]
public class QueryController : BaseQueryController
{
public QueryController(IQueryProcessor queryProcessor) : base(queryProcessor)
{
}
}
}
Inherit from BaseQueryController
and pass the IQueryProcessor
to the base constructor.
The action method from the base class will handle all queries:
[HttpPost]
[Route("{queryName}")]
public async Task<object> Handle(string queryName, [FromBody] Newtonsoft.Json.Linq.JObject json)
- The action is requested via HTTP
POST
with the Content-Typeapplication/json
in the header. - The name of the query is the slug of the URL.
- The query itself is provided as JSON in the body.
- If the query succeeds; the response is the result as JSON with the HTTP status code
200
. - If the query fails; the response is an error message with the HTTP status code
400
or500
.
Example of a query request via curl:
curl -X POST -d "{'Id':1}" http://localhost:57857/api/query/BarQuery --header "Content-Type:application/json"
Configure services in Startup.cs
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc();
// Add queries.
services.AddQueries(typeof(Startup).Assembly);
}
The extension method AddQueries
will add all query handlers in the given assemblies to the dependency injection container.
You can pass in a params
array of Assembly
arguments if your query handlers are located in different projects.
If you only have one project you can use typeof(Startup).Assembly
as a single argument.
- Provides generic function support for commands and queries with HTTPTriggers
- Enables APIs based on HTTP
POST
Download from NuGet: https://www.nuget.org/packages/CommandQuery.AzureFunctions/
Example code:
CommandQuery.Sample.AzureFunctions.Vs1
- Azure Functions v1 (.NET Framework)CommandQuery.Sample.AzureFunctions.Vs2
- Azure Functions v2 (.NET Core)
Support for Azure Functions Core Tools has been discontinued.
- Create a new Azure Functions project
- Install the
CommandQuery.AzureFunctions
package from NuGetPM>
Install-Package CommandQuery.AzureFunctions
- Create functions
- For example named
Command
andQuery
- For example named
- Create commands and command handlers
- Implement
ICommand
andICommandHandler<in TCommand>
- Implement
- Create queries and query handlers
- Implement
IQuery<TResult>
andIQueryHandler<in TQuery, TResult>
- Implement
When you create a new project in Visual Studio you need to choose the runtime:
- Azure Functions v1 (.NET Framework)
- Azure Functions v2 (.NET Core)
Add a Command
function in Azure Functions v1 (.NET Framework):
using System.Net.Http;
using System.Threading.Tasks;
using CommandQuery.AzureFunctions;
using CommandQuery.Sample.Commands;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
namespace CommandQuery.Sample.AzureFunctions.Vs1
{
public static class Command
{
private static readonly CommandFunction Func = new CommandFunction(typeof(FooCommand).Assembly.GetCommandProcessor());
[FunctionName("Command")]
public static async Task<HttpResponseMessage> Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "command/{commandName}")] HttpRequestMessage req, TraceWriter log, string commandName)
{
return await Func.Handle(commandName, req, log);
}
}
}
Add a Command
function in Azure Functions v2 (.NET Core):
using System.Threading.Tasks;
using CommandQuery.AzureFunctions;
using CommandQuery.Sample.Commands;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
namespace CommandQuery.Sample.AzureFunctions.Vs2
{
public static class Command
{
private static readonly CommandFunction Func = new CommandFunction(typeof(FooCommand).Assembly.GetCommandProcessor());
[FunctionName("Command")]
public static async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "command/{commandName}")] HttpRequest req, TraceWriter log, string commandName)
{
return await Func.Handle(commandName, req, log);
}
}
}
- The function is requested via HTTP
POST
with the Content-Typeapplication/json
in the header. - The name of the command is the slug of the URL.
- The command itself is provided as JSON in the body.
- If the command succeeds; the response is empty with the HTTP status code
200
. - If the command fails; the response is an error message with the HTTP status code
400
or500
.
Example of a command request via curl:
curl -X POST -d "{'Value':'Foo'}" http://localhost:7071/api/command/FooCommand --header "Content-Type:application/json"
Add a Query
function in Azure Functions v1 (.NET Framework):
using System.Net.Http;
using System.Threading.Tasks;
using CommandQuery.AzureFunctions;
using CommandQuery.Sample.Queries;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
namespace CommandQuery.Sample.AzureFunctions.Vs1
{
public static class Query
{
private static readonly QueryFunction Func = new QueryFunction(typeof(BarQuery).Assembly.GetQueryProcessor());
[FunctionName("Query")]
public static async Task<HttpResponseMessage> Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "query/{queryName}")] HttpRequestMessage req, TraceWriter log, string queryName)
{
return await Func.Handle(queryName, req, log);
}
}
}
Add a Query
function in Azure Functions v2 (.NET Core):
using System.Threading.Tasks;
using CommandQuery.AzureFunctions;
using CommandQuery.Sample.Queries;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
namespace CommandQuery.Sample.AzureFunctions.Vs2
{
public static class Query
{
private static readonly QueryFunction Func = new QueryFunction(typeof(BarQuery).Assembly.GetQueryProcessor());
[FunctionName("Query")]
public static async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "query/{queryName}")] HttpRequest req, TraceWriter log, string queryName)
{
return await Func.Handle(queryName, req, log);
}
}
}
- The function is requested via HTTP
POST
with the Content-Typeapplication/json
in the header. - The name of the query is the slug of the URL.
- The query itself is provided as JSON in the body.
- If the query succeeds; the response is the result as JSON with the HTTP status code
200
. - If the query fails; the response is an error message with the HTTP status code
400
or500
.
Example of a query request via curl:
curl -X POST -d "{'Id':1}" http://localhost:7071/api/query/BarQuery --header "Content-Type:application/json"
You can integration test your controllers and command/query handlers with the Microsoft.AspNetCore.TestHost
.
Example code: CommandQuery.Sample.Specs
.
Test commands:
using System.Net.Http;
using System.Text;
using CommandQuery.Sample.AspNetCore;
using CommandQuery.Sample.AspNetCore.Controllers;
using Machine.Specifications;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
namespace CommandQuery.Sample.Specs.AspNetCore.Controllers
{
public class CommandControllerSpecs
{
[Subject(typeof(CommandController))]
public class when_using_the_real_controller
{
Establish context = () =>
{
Server = new TestServer(new WebHostBuilder().UseStartup<Startup>());
Client = Server.CreateClient();
};
It should_work = () =>
{
var content = new StringContent("{ 'Value': 'Foo' }", Encoding.UTF8, "application/json");
var response = Client.PostAsync("/api/command/FooCommand", content).Result;
response.EnsureSuccessStatusCode();
var result = response.Content.ReadAsStringAsync().Result;
result.ShouldBeEmpty();
};
It should_handle_errors = () =>
{
var content = new StringContent("{ 'Value': 'Foo' }", Encoding.UTF8, "application/json");
var response = Client.PostAsync("/api/command/FailCommand", content).Result;
response.IsSuccessStatusCode.ShouldBeFalse();
var result = response.Content.ReadAsStringAsync().Result;
result.ShouldEqual("The command type 'FailCommand' could not be found");
};
static TestServer Server;
static HttpClient Client;
}
}
}
Test queries:
using System.Net.Http;
using System.Text;
using CommandQuery.Sample.Queries;
using CommandQuery.Sample.AspNetCore;
using CommandQuery.Sample.AspNetCore.Controllers;
using Machine.Specifications;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
namespace CommandQuery.Sample.Specs.AspNetCore.Controllers
{
public class QueryControllerSpecs
{
[Subject(typeof(QueryController))]
public class when_using_the_real_controller
{
Establish context = () =>
{
Server = new TestServer(new WebHostBuilder().UseStartup<Startup>());
Client = Server.CreateClient();
};
It should_work = () =>
{
var content = new StringContent("{ 'Id': 1 }", Encoding.UTF8, "application/json");
var response = Client.PostAsync("/api/query/BarQuery", content).Result;
response.EnsureSuccessStatusCode();
var result = response.Content.ReadAsAsync<Bar>().Result;
result.Id.ShouldEqual(1);
result.Value.ShouldNotBeEmpty();
};
It should_handle_errors = () =>
{
var content = new StringContent("{ 'Id': 1 }", Encoding.UTF8, "application/json");
var response = Client.PostAsync("/api/query/FailQuery", content).Result;
response.IsSuccessStatusCode.ShouldBeFalse();
var result = response.Content.ReadAsStringAsync().Result;
result.ShouldEqual("The query type 'FailQuery' could not be found");
};
static TestServer Server;
static HttpClient Client;
}
}
}
Example code: CommandQuery.Sample.Specs
.
Fake log:
using System.Diagnostics;
using Microsoft.Azure.WebJobs.Host;
namespace CommandQuery.Sample.Specs.AzureFunctions.Vs2
{
public class FakeTraceWriter : TraceWriter
{
public FakeTraceWriter() : base(TraceLevel.Off)
{
}
public override void Trace(TraceEvent traceEvent)
{
}
}
}
Test commands:
using System.IO;
using CommandQuery.Sample.AzureFunctions.Vs2;
using Machine.Specifications;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http.Internal;
using Microsoft.AspNetCore.Mvc;
namespace CommandQuery.Sample.Specs.AzureFunctions.Vs2
{
public class CommandSpecs
{
[Subject(typeof(Command))]
public class when_using_the_real_function
{
It should_work = () =>
{
var req = GetHttpRequest("{ 'Value': 'Foo' }");
var log = new FakeTraceWriter();
var result = Command.Run(req, log, "FooCommand").Result as EmptyResult;
result.ShouldNotBeNull();
};
It should_handle_errors = () =>
{
var req = GetHttpRequest("{ 'Value': 'Foo' }");
var log = new FakeTraceWriter();
var result = Command.Run(req, log, "FailCommand").Result as BadRequestObjectResult;
result.Value.ShouldEqual("The command type 'FailCommand' could not be found");
};
static DefaultHttpRequest GetHttpRequest(string content)
{
var httpContext = new DefaultHttpContext();
httpContext.Features.Get<IHttpRequestFeature>().Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content));
return new DefaultHttpRequest(httpContext);
}
}
}
}
Test queries:
using System.IO;
using CommandQuery.Sample.AzureFunctions.Vs2;
using CommandQuery.Sample.Queries;
using Machine.Specifications;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Http.Internal;
using Microsoft.AspNetCore.Mvc;
namespace CommandQuery.Sample.Specs.AzureFunctions.Vs2
{
public class QuerySpecs
{
[Subject(typeof(Query))]
public class when_using_the_real_function
{
It should_work = () =>
{
var req = GetHttpRequest("{ 'Id': 1 }");
var log = new FakeTraceWriter();
var result = Query.Run(req, log, "BarQuery").Result as OkObjectResult;
var value = result.Value as Bar;
value.Id.ShouldEqual(1);
value.Value.ShouldNotBeEmpty();
};
It should_handle_errors = () =>
{
var req = GetHttpRequest("{ 'Id': 1 }");
var log = new FakeTraceWriter();
var result = Query.Run(req, log, "FailQuery").Result as BadRequestObjectResult;
result.Value.ShouldEqual("The query type 'FailQuery' could not be found");
};
static DefaultHttpRequest GetHttpRequest(string content)
{
var httpContext = new DefaultHttpContext();
httpContext.Features.Get<IHttpRequestFeature>().Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content));
return new DefaultHttpRequest(httpContext);
}
}
}
}
- Select your
AspNetCore
orAzureFunctions
project and Set as StartUp Project - Run the project with
F5
- Manual test with Postman
You can import these collections in Postman to get started: