A service layer (Java API in this case) is backed by dozens of services (over the network in an SOA) and used by dozens of clients, each using different subsets of the overall API. In our case all client interaction happens within a single JVM so we can know which clients are accessing what services, fields and keys (versus a RESTful API over HTTP where this would be unknown).
Principally the API is functionality focused rather than system focused. A client should request data to serve a given use case and not need to concern themselves with the underlying implementation details of what backend system the data is coming from.
Today this is achieved through static typing and Java object modeling. Types from underlying services are decoupled from clients so that backend systems can change their implementations without impacting clients.
This means (assuming Java language environment):
- All backend services are wrapped by objects that originate in the API Service Layer so that underlying dependencies are not leaked. This is done so that backend type systems are decoupled from frontend clients.
- Types end up being validated at deserialization time in the network client.
- Documentation is generated from the static object model as Javadocs.
- Code completion is enabled by static typing in IDEs to help with discovery.
Problems are:
- Static typing does not communicate or enforce entire contract
- Static types tell us the 'shape' of the data but not the possible values, lengths, ranges, existence etc
- All backend services must be wrapped by the API Service Layer which means any type change (including field addition) involves the API Service Layer team being involved.
- Production breakage can still occur when backend systems change behavior and values that the static typing does not catch, or that happens at the remote server and breaks at deserialization time.
- Static types couple modules together as they leak across boundaries. This is why new types must be created and wrap around underlying services to decouple the layers.
We want to enable rapid iteration while balancing safety concerns.
This means:
- We want backend services to be as loosely coupled as possible and be able to change as much as they can without the API changing.
- We want services to know if they have changed something that breaks a client before it goes out to full production traffic.
- We need services to have a deprecation lifecycle to deprecate something, communicate to users to migrate off and know when it's no longer being used so they can remove it.
- We would like the API Service Layer to:
- expose functionality instead of infrastructure to clients
- enforce common abstractions such as rx.Observable for asynchronous execution and composition
- get out of the way of services adding/removing/modifying data as long as client expectations and requirements are met
- provide clear documentation of all available functionality include what to expect from the data and how to code against it
- protect against underlying services breaking the contracts
A possible alternative solution is making static typing optional or non-existent and using "consumer driven contracts" or specifications of some kind to achieve safety and discovery requirements while enabling rapid iteration
The clients and services would achieve a higher decoupling and the API Service Layer would stop defining the types and shape of data, thus decoupling it from the everchanging shape of data flowing through it. The API Service Layer would still remain a broker of connectivity and functionality and a tool for asserting correctness and safety but it would enable this without injecting artificial layers of types between clients and services.
The contracts need to address the following aspects of data:
- possible values
- what elements are being used
- example usage
- assertions on data values
- versioning and deprecation (end-of-life + migration)
We should be able to use them to:
- generate documentation
- generate examples
- execution functional tests
We should use metrics to track exactly what keys are being used by what endpoints and derive "implicit contracts" for clients that tell the system what each client is consuming.
Clients will get implicit contracts automatically by using the API Service Layer which can be used to communicate to services what keys/fields are being used and thus can't be removed or changed. Clients should also be able to provide explicit contracts with assertions of the data they expect and need for their client to work. It should be possible for all assertions by clients for a given API function/method to be aggregated into a view of what that function is required to do and perform functional testing in continous build and deployment processes as well as autogenerate documentation for clients and reveal to backend service owners how their data is being used. These same assertions could become unit tests for services.
In summary, the API Service Layer would "connect the pipes" between services and clients with common abstractions (behavior and functionality instead of infrastructure, Observable, etc) but ignore the "shape" of the data passing between them and allow the clients to define what is expected implicitly and explicitly.
Instead of compile time checks (which only account for a portion of failure states anyways) the API Service Layer system would provide tools that:
- assert correctness in dev/test/prod environments by executing functional tests derived from explicit and implicit contracts
- expose usage information to services for use in unit and functional tests
- enable lifecycle management for deprecating keys/fields/functions, communicating to clients using them and reporting to services when all use has stopped
- automate documentation generation including not just available keys/fields but assertions about the expected data for each key/field and possibly tests showing sample usage
Lifecycle:
- New Functionality/Service
If a new service or functionailty needs to be added to the API Service layer, the API team would be involved in exposing this as a new method. For example:
Observable<Map<String, String>> getSocialFriends(User user)
It would not however create an artificial type hierarchy to represent the return type, it would allows dictionaries of data to be returned.
The API team could include default assertions and contracts that are known to be global and then client teams could add more as they see fit.
- New Key/Field
The team controlling the backend system for getSocialFriends
functionality could add new key/value pairs whenever they want and it would just start flowing through the system without any changes.
- Remove Key/Field
The backend team could stop sending a key and that could break clients if it was depended on.
Safety against this (today only done at compile time, but not if the backend service deploys and changes the network response) would exist as runtime functional tests. The service team could apply these assertions to catch the mistake of deleting a key that is in use. If they do not apply these assertions then runtime assertions in the API system would catch them and alert.
The API system would provide tools so the backend team knows what keys are used.
- Change Key/Field Behavior
Similar to removing, using the API tools they can see who is using the keys and determine what kind of change can be performed if any.
Presentation on core.specs (a Clojure library) and how a specification can enable things such as:
- contracts
- generative tests
- typing
- model-checking
- example usage
- enhanced documentation
“Types are inherently non-local - they describe data that flows across module boundaries.”
Martin Fowler - Consumer Driven Contracts
A business can only fully realise these benefits, however, if its SOA enables services to evolve independently of one another. To increase service independence, we build services that share contracts, not types.
...
Our service community is frustrated in its evolution because each consumer implements a form of "hidden" coupling that naively reflects the entirety of the provider contract in the consumer's internal logic. The consumers, through their use of XSD validation, and to a lesser extent, static language bindings derived from a document schema, implicitly accept the whole of the provider contract, irrespective of their appetite for processing the component parts.
...
David Orchard provides some clues as to how we might have avoided this issue when he alludes to the Internet Protocol's Robustness Principle: "In general, an implementation must be conservative in its sending behaviour and liberal in its receiving behaviour". We can augment this principle in the context of service evolution by saying that message receivers should implement "just enough" validation: that is, they should only process data that contributes to the business functions they implement, and should only perform explicitly bounded or targeted validation of the data they receive - as opposed to the implicitly unbounded, "all-or-nothing" validation inherent in XSD processing.
...
Here then is a relatively lightweight solution to our contract and coupling problems, and one that doesn't require us to add obscure meta-informational elements to a document. So let's roll back time once again, and reinstate the simple schema described at the outset of the article. But this time round, we'll also insist that consumers are liberal in their receiving behaviour, and only validate and process information that supports the business functions they implement (using Schematron schemas rather than XSD to validate received messages). Now when the provider is asked to add a description to each product, the service can publish a revised schema without disturbing existing consumers. Similarly, on discovering that the InStock field is not validated or processed by any of the consumers, the service can revise the search results schema - again without disturbing the rate of evolution of each of the consumers.
...
[On consumer contracts] … By implementing these tests, the provider gains a better understanding of how it can evolve the structure of the messages it produces without breaking existing functionality in the service community. And where a proposed change would in fact break one or more consumers, the provider will have immediate insight into the issue and be better able to address it with the parties concerned, accommodating their requirements or providing incentives for them to change as business factors dictate.
...
By going a little further and introducing unit tests that assert each expectation, we can ensure that contracts are described and enforced in a repeatable, automated fashion with each build. In more sophisticated implementations, expectations can be expressed as Schematron- or WS-Policy-like assertions that are evaluated at runtime in the input and output pipelines of a service endpoint.
...
We've suggested that systems built around consumer-driven contracts are better able to manage breaking changes to contracts. But we don't mean to suggest that the pattern is a cure-all for the problem of breaking changes: when all's said and done, a breaking change is still a breaking change. We do believe, however, that the pattern provides many insights into what actually constitutes a breaking change, and as such may serve as the foundation for a service versioning strategy. Moreover, as we've already discussed, service communities that implement the pattern are better placed to anticipate the effects of service evolution. Provider development and operations teams in particular can more effectively plan their evolutionary strategies - perhaps by deprecating contractual elements for a specific period and simultaneously targeting recalcitrant consumers with incentives to move up to new versions of a contract.
...
Consumer-driven contracts do not necessarily reduce the coupling between services. Loosely-coupled services are relatively independent of one another, but remain coupled nonetheless. What the pattern does do, however, is excavate and put on display some of those residual, "hidden" couplings, so that providers and consumers can better negotiate and manage them.
The idea [of Janus] was to write the contracts in such a way that as well as using the contract to verify the behaviour of the service, use that very same contract to provide mocks to the consuming applications. Executing the contract in one mode would test the service; executing it in another mode would create an out-of-process mock server. As soon as the client team writes the contracts, they would get immediate value out of it.
(service
"Service name"
(contract "contract name"
(method <one of :get, :post, :put or :delete>)
(url "full, absolute URL to the service")
(header "header name" "header value")
(body <:xml, :json or :string>
<Clojure data structure that will be serialized as above>)
(should-have :path "json path" :matching <regex>)
(should-have :path "json path" :of-type <:string, :array, :object or :number>)
(should-have :path "json path" :equal-to <value>)
)
)
I've read this three times now and, since I'm kinda dumb, I'm still digesting. One thing about Ben's comment:
I think this is raises an interesting question about the service layer: Is it an interface, or an abstraction? Ben's comment seems to imply it's an interface, i.e. each part of the SL is basically a wrapper around a particular service and types are associated with that service. The SL provides a consistent, cohesive way of coding against a bunch of services, but it doesn't do much to abstract or "take away" from the functionality those services provide. Turning
int
intoAPIVideo
is basically just sugar.On the other hand, as Jason notes, if the SL is a "business view" aggregating multiple services into a use-case-driven facade for clients, then it's an abstraction. It exposes enough for the clients to do what they need to do.
In reality, the SL is probably a bit of both. For well-established use cases, it's more of an abstraction, reducing the number of fiddly details a client developer has to keep track of to do the same thing a dozen teams have done before. If they need to do something more, they're stuck.
For newer services, it's just an interface. Everything's in flux so the best the SL can do is act as a pass-through and try to stay out of the way. Here clients probably have to do more fiddly coding since the API is more raw. But, both ends can evolve rapidly.
What's the point? Maybe thinking of where API lies on this continuum (is it a continuum? can both extremes be achieved?) and where we want it to be could guide the discussion/design/etc. I don't know, maybe I just said what Jason and Ben said in a different way :)