This is still in rough draft form. I will continue to make edits as I receive them. I will collate the list of feedback here and make notes of the changelog as revisions are made.
No string interpolation for building your routes to your resources.
Example: Given a service URL is a pattern like: https://pizza-service/customers/{customer_id}/orders{?page,per_page,query}
// Bad
let url = apiHost + "/customers/" + customerId + "/orders";
let url = `${apiHost}/customers/${customerId}/orders`;
// Better
let url = PizzaService.Routes.urlFor('customer-orders', { customer_id: 'CUS-1234' });
Help to guide around best practices of making requests. Eliminating arbitrarily chained promises and instead using well defined modeling and interfaces. Just as there is callback hell, there is also promise hell if not managed well.
This encapsulates the access in the objects and operations. If we see going into raw HTTP requests and responses, that should raise a flag.
Example: Given I want to access a customers pending orders, sorted descending by their created date.
// Bad
axios.get(`https://pizza-service/customers/${customerId}/orders`)
.then(response => {
let data = response.data;
return data.filter((attr) => {
return data.status_name === 'pending';
}).order((a, b) => {
return Date.parse(a.created_at) - Date.parse(b.created_at);
});
}).catch(error => {
});
// Better
let orders = await client.models.orders.list({ customer_id: customerId }).pending().order({ created_at: 'desc' });
let orders = await client.models.orders.list({ customer_id: customerId, is_pending: true }).order({ created_at: 'desc' });
By having things encapsulated in the models, we can the benefits of collections (enumerable) and models (instances, comparable).
Another benefit here is the encapsulation can be intelligent about how it may need to perform an operation. If I need to get 'pending' orders, it may do so via:
- API with filters: This would be the most efficient. It would use the API and proxy that request on.
- API with pagination: This would be least efficient. It would auto-paginate and then map/reduce the result set.
- Local with filters: This would perform the action on a local data set.
No matter what approach is taken, we keep that logic encapsulated in the model and don't leave it to the consumers to figure out (again, guidance on best practices, with flexibility to do what's needed).
The end result would be the same: the filtered collection of pending orders.
Relies on composition. Each part can be used on its own. This does not require a connection to use modeling. It does not require requests to use modeling. It builds up from the components and provides opinionated access while also giving flexibility to customize or build as needed.
By using composition, we are not tightly bound to any one part. Many SDKs require a connection or are meta-built from some other documentation output.
Example: Given I have a cached data set or a local copy of a Schema (model) and I want to use it.
// Bad
let orders = otherClient.getOrders();
// Better
let localOrdersCollection = [];
let orders = new PizzaService.Models.Orders(localOrdersCollection);
console.log(orders.totalAmountUnit());
// 34.99 USD
orders.line_items.each((line_item) => {
console.log(line_item.name);
});
In the first example my model is tightly bound to the entire system and requires a connection to a remote service.
In the second example I can use it to interact with the core data model. I can work with the object and not inline with raw attributes. I can even load nested attributes to test the associations as needed.
It is not tightly bound to any adapter. It will use Axios by default, but could also use fetch or XMLHTTP. The SDK defines the rules and interfaces and all adapters are built to that contract.
Since everything is de-coupled, it also permits you to use the raw underlying adapter if needed. You could even extend to create a FileSystem adapter that responds to the necessary interface.
// Bad
let orders = otherClient.getOrders();
// Good
let fsAdapter = PizzaService.Adapters.retrieve('fs');
let axiosAdapter = PizzaService.Adapters.retrieve('axios');
let fetchAdapter = PizzaService.Adapters.retrieve('fetch');
// FS
let client = new PizzaService.Client({ adapter: fsAdapter });
// Will retrieve according to the underlying adapter that maps resources to FS objects. It returns a FS Response.
let orders = client.models.customers.retrieve('CUS-1234');
// Axios
let client = new PizzaService.Client({ adapter: axiosAdapter });
// Will use the Axios adapter to retrieve the data. It returns an HTTP Response
let orders = client.models.customers.retrieve('CUS-1234');
// Fetch
let client = new PizzaService.Client({ adapter: fetchAdapter });
// Will use the Fetch adapter to retrieve the data. It returns an HTTP Response
let orders = client.models.customers.retrieve('CUS-1234');
In the first example we may use directly, or have returned to us, the Axios instance. This causes an underlying dependency to bleed through to our consumers.
In the other examples we're using our interface for requests and responses and using coresponding adapters. The consumer will only ever see or interact with our models (Request/Response interfaces)
It decorates and enriches the console
to aid in development. This would be the equivalent of tailing a log in other services. This is important for visibility in both development and production.
It provides a namespace for the wrapped methods, permitting us to grep the console for only the things we care about.
The default logger here uses the console
, but it could just as easily broadcast to other services as needed (File, Exception Service, etc)
// Bad
console.error('My error that will get included in all other errors');
// My error that will get included in all other errors
// Better
let logger = new PizzaService.Logger(console);
logger.error('My error will now include a named prefix');
// '[SERVICE_CLIENT] My error will now include a named prefix'
By wrapping the console (or any other broadcaster), we can adhere to a logging interface and tag our own package messages accordingly. This permits me to then grep for '[SERVICE_CLIENT]' or similar to only see the logs I care about.
It exposes only a subset of exceptions to ensure other dependencies do not bleed through. A consumer can use the SDK with confidence.
// Bad
try {
let orders = otherClient.getOrders();
} catch(e) {
if(typeof e == 'ExpectedClientError') {
}
}
// Error: uncaught exception [AxiosError]
// Better
try {
let orders = await client.requests.orders.list();
} catch(e) {
if(typeof e === 'ApplicationError') {
logger.error(e);
}
}
In the first example a developer may program around the expectation that they get a localized exception. However, the client ended up with an error from an underlying dependency being exposed. This was not accounted for. And, even if it was, it means the developer needs to know the underlying dependencies and possible exceptions that could be thrown.
In the second example, the SDK has trapped all errors in the underlying route to retrieving the orders list. The exception will always be the ApplicationError and will preserve any stack trace that's needed.
Developers are often familiar with the Callback Hell (Callback Pyramid). That leads to nested callback functions within callback functions.
Now developers rely on promises. However, this can still lead to Promise Hell. The only difference is this one is flattened. But, with each chained .then
you need to know what you're dealing with and manage the state from the prior promises (or indexed promises). Yes, it will read linearly, but I often challenge developers to tell me what is expected in the third chained .then
in a stack.
I often see this as akin to a child saying their room is cleaned simply because they shoved everything in the closet. They didn't clean the room, they just moved it or called it by a new name.
What I care about is the objective. If I have to wait for multiple service calls to finish before being able to do things, then my blocker is the HTTP request(s).
For instance, I may place an Order. Then, once it's placed I need to immediately return a Status object. This will encompass my Deliveries and Transactions (Payments). I may need the depth of several objects to retrieve what's needed.
// Meh
let order = client.models.orders.create(orderParams)
.then((resolve,reject) => {
reject(new Error('Oops'));
resolve(return new PizzaService.Models.Order(result.asJson()));
}.then((order) => {
return new order.getStatus();
}));
// Better
let orderParams = {
}
let order = await client.models.orders.create(orderParams);
let status = await order.status;
let deliveries = await order.deliveries;
// Or
order.with_status('pending', ((order) => {
// Order is still pending
}));
order.with_status('payment_settled', ((order) => {
}));
order.with_status('delivery_settled', ((order) => {
}));
order.with_status('settled', ((order) => {
}));
In the first example we tightly bind all interactions to the promise chain.
In the second example we can retrieve the objects independently of one another. We can also expose interfaces to deal with things as we see fit.