In our project which is an ASP.NET MVC application, quite a lot of features are implemented at the client side, with JavaScript, which is talking with the server using JSONs. Besides the web client, we have a mobile client application, which is driven by the separate API, provided by the same MVC application, a bit WebAPI style, again using JSONs. No rocket science.
As all of these three parts are growing and changing quickly (sometimes way too quickly), we were struggling with incompatibility between what the JavaScript or mobile client expects to receive and what the server is returning, due to the changes not being applied on both sides. Refactorings, functional changes or even correcting a typo affected these server-client interactions. Changing a property name is so easy with ReSharper that we're often not taking enough attention to spot possible impact at the borders between the layers. What's worse, we're relying heavily on the default model binding behavior of ASP.NET MVC - this means that even parameter names in action methods became part of our public API that we need to take care of. And by taking care I mean either not ever changing once-published name, or update all the possible clients together with the server-side backend deployment (that includes forcing all mobile app users to upgrade - nightmare!).
We decided we need a cheap, reliable and universal method that prevents unintended changes in our public contracts - action names, parameter types and names, returned types structures, names of properties etc.
Good and well maintained suite of integration tests will probably do the job, but unfortunately we still don't have one (and we're not the last ones, are we?). And I suppose the suite would be quite huge to cover good range of unintentional changes we can possibly introduce. Second thought was to implement some quirky tests that would use reflection to go through the codebase and fail if the implementation differs from what we expect. But it would be better to invest that considerable amount of time needed to write these kind of specifications in writing real integration tests.
Finally we took a simpler approach. We decided to be more explicit in what is our external API and what is not. We've created a binary contract definition using few steps. Let's see it on this simple example:
public class StadiumController { public ActionResult GetByCapacity(int? minCapacity, int? maxCapacity) { var min = minCapacity ?? 0; var max = maxCapacity ?? int.MaxValue; return Json(Stadiums.Where(x => x.Capacity >= min && x.Capacity <= max)); } }
1. enclose the input parameters of action methods in "input model" classes
public class StadiumByCapacityInputModel { public int? MinCapacity { get; set; } public int? MaxCapacity { get; set; } } public class StadiumController { public ActionResult GetByCapacity(StadiumByCapacityInputModel input) { var min = input.MinCapacity ?? 0; var max = input.MaxCapacity ?? int.MaxValue; return Json(Stadiums.Where(x => x.Capacity >= min && x.Capacity <= max)); } }
2. change the return types of action methods to "output model" classes
public class StadiumByCapacityInputModel { public int? MinCapacity { get; set; } public int? MaxCapacity { get; set; } } public class StadiumOutputModel { public string Name { get; set; } public int Capacity { get; set; } } public class StadiumController { public IEnumerable<StadiumOutputModel> GetByCapacity(StadiumByCapacityInputModel input) { var min = input.MinCapacity ?? 0; var max = input.MaxCapacity ?? int.MaxValue; var stadiums = Stadiums.Where(x => x.Capacity >= min && x.Capacity <= max); return stadiums.Select(x => new StadiumOutputModel() { Name = x.Name, Capacity = x.Capacity }; } }
3. extract interfaces from the controllers
public class StadiumByCapacityInputModel { public int? MinCapacity { get; set; } public int? MaxCapacity { get; set; } } public class StadiumOutputModel { public string Name { get; set; } public int Capacity { get; set; } } public interface IStadium { IEnumerable<StadiumOutputModel> GetByCapacity(StadiumByCapacityInputModel input); } public class StadiumController : IStadium { public IEnumerable<StadiumOutputModel> GetByCapacity(StadiumByCapacityInputModel input) { var min = input.MinCapacity ?? 0; var max = input.MaxCapacity ?? int.MaxValue; var stadiums = Stadiums.Where(x => x.Capacity >= min && x.Capacity <= max); return stadiums.Select(x => new StadiumOutputModel() { Name = x.Name, Capacity = x.Capacity }; } }
4. move these interfaces and input/output models far from the controller so that ReSharper-driven refactorings do not affect it - to the separate "contract" libraries.
5. include the libraries as referenced DLLs in our project
6. tweak ASP.NET MVC's default ActionInvoker to handle non-ActionResult return types (not needed with ASP.NET Web API - the actions in Web API controllers by design return POCO objects)
Now we treat the contract libraries like separate projects. We actually keep them in Libs folder and check it in to our main project as binaries, but just having it in a separate solution will do the job. The solution for contract library is configured so that the built output files go directly into the Libs folder, what is not possible without manual check-out of the previous binaries. This guarantees that no one checks in any changes in contract code without the new contract binaries and it also raises the level of explicitness. We've effectively made the development around contracts more difficult to ensure that all the changes done in contracts definitions are made deliberately and with proper consideration.
Whenever someone breaks the contract requirements (without modifying the binary contract properly), the project just doesn't compile - either the interface is not implemented or there is some kind of type mismatch. Moreover, having the contract definition in a separate physical project makes managing, documenting or versioning easier.
There is one thing that may seem to be a serious downside. We need to map our input model classes from contract to some "real" domain objects from the main codebase in order to use it. And the same with return types - we often need to map domain objects back to match types defined in the contract. It is a lot more fuss, but again - it makes the contract very explicit and visible. Easy cases of mapping can be handled with the tools like AutoMapper. More complicated cases may exist when the codebase starts to differ from the contract and we need to keep backward compatibility (like when the clients are mobile apps). In that cases again - it's even better to have all the transformations explicit and in one place and the mapping code becomes more helpful than annoying.
No comments:
Post a Comment
Note: Only a member of this blog may post a comment.