Exploring Sample Software Application Layers And Responsibilities
A couple of months ago, I summarized how I've been thinking about software application layers and the responsibility of those various layers. Of course, putting it on paper and putting it into action are two very different things. And, since then, I've found myself getting lost and confused as to which parts of the application belong in which layer. As such, I wanted to post a pseudo-example to see if I could get any feedback on my approach.
In my application, a user can post a comment to a conversation (ie. comment thread). This can be done in two different ways. Either the user can post the comment to the API (using the web interface); or, the user can post the comment via email, which uses an inbound email WebHook. In these two situations, there is a good deal of overlap; but, there is also a divergence in security measures.
As I've tried to think through these two points of contact, I've tried very hard to keep in mind which aspects of the workflow have "connaissance;" meaning, which aspects of the workflow are "born together," and therefore must be encapsulated behind the same method call. To explain, let's look at the actions that must be taken when someone posts a comment via the API:
- Authenticate user.
- Validate existence of comment thread.
- Check to see if user is authorized to post to comment thread.
- Turn on comment subscription for user.
- Post comment.
- Create unread-comment flags for thread subscribers (less the given user).
- Send comment emails to thread subscribers (less the given user).
- Push new comment to Realtime API.
Of these steps, the first three are primarily concerned with security - they don't really speak to data-model integrity. The last two steps are concerned with talking to 3rd party systems - they also don't really speak to data-model integrity. This leaves us with the following three steps:
- Turn on comment subscription for user.
- Post comment.
- Create unread-comment flags for thread subscribers (less the given user).
But, are all of these really "born together?"
I think it makes sense that I always want to couple the creation of a comment and the creation of "unread" comment flags. It seems that these two items are indelibly linked; when I have a new comment, I have to have unread comment flags. I can never have unread comment flags for a comment that doesn't exist. And, I would never want to create a comment without creating the unread comment flags.
But what about this action of subscribing the posting user to comment notifications? This seems highly related; but, it doesn't feel necessary. I could see creating a user interface that would allow a user to post a comment and not subscribe to notifications. In fact, most blogs work this way (ie. there is typically a checkbox for comment notifications). As such, it seems to me that comment subscription and comment posting are related, but not "born together."
This thought-path means that, of the above steps, there are only two that have connaissance:
- Post comment.
- Create unread-comment flags for thread subscribers (less the given user).
The rest of the steps are just that - "steps" in a workflow. Only the above two actions can be encapsulated as a single step.
Anyway, here's some pseudo code that looks at the workflow and separation of posting a comment through the API and through the WebHook. The first two items are the related Controllers; the second two items are the related "Workflow" objects (ie. the application layer).
<cfscript>
/*
NOTE: THIS IS ALL PSEUDO CODE. I'M JUST TRYING TO THINK SOME OF
THIS STUFF THROUGH. CREATING A WELL-LAYERED APPROACH IS SOMETHING
THAT I AM STILL STRUGGLING TO WRAP MY HEAD AROUND.
# Post comment via WebHook.
===============================
Authorize url endpoint.
Normalize WebHook post data.
Get user based on post data.
Validate message authentication against user's signing key.
Validate existence of comment thread.
Check to see if user is authorized to post to comment thread.
Turn on comment subscription for user.
Post comment.
Create unread-comment flags for thread subscribers (less the given user).
Send comment emails to thread subscribers (less the given user).
Push new comment to Realtime API.
# Post comment via API
===============================
Authenticate user.
Validate existence of comment thread.
Check to see if user is authorized to post to comment thread.
Turn on comment subscription for user.
Post comment.
Create unread-comment flags for thread subscribers (less the given user).
Send comment emails to thread subscribers (less the given user).
Push new comment to Realtime API.
*/
// =============================================================== //
// =============================================================== //
// The controller layer for the WebHook request. In this request,
// there is no "user". There is simply a 3rd party system that is
// posting to a WebHook endpoint within our system.
component {
function handleWebHook( context ) {
if ( context.password != webhookPassword ) {
throw( "Unauthorized" );
}
var requestBody = getHttpRequestData().content;
var post = deserializeJSON( requestBody );
workflowLayer.postComment( post );
}
}
// =============================================================== //
// =============================================================== //
// The controller layer for the API request. In this request, there
// is a user that should already be authorized and sending along
// cookies with each request to denote this authorization.
component {
function handleAPI( context ) {
if ( ! context.user.isAuthorized ) {
throw( "Unauthorized" );
}
workflowLayer.postComment(
context.user.id,
context.conversationID,
context.comment
);
}
}
// =============================================================== //
// =============================================================== //
// =============================================================== //
// =============================================================== //
// The application / coordination / workflow layer for the WebHook
// request. This handles the lower-level security and coordinates
// related events that must take place around posting a comment.
component {
function postComment( post ) {
post = webhookService.normalize( post );
var user = userCache.get( post.userID );
if ( ! user ) {
throw( "NotFound" );
}
if ( ! webhookService.isAuthorized( post, user.secretKey ) ) {
throw( "Unauthorized" );
}
var conversation = conversationCache.get( post.conversationID );
if ( ! conversation ) {
throw( "NotFound" );
}
if ( ! conversationService.canUserPostComment( post.conversationID, user.id ) ) {
throw( "Unauthorized" );
}
transaction {
conversationService.subscribeUser( conversation.id, user.id );
// Post comment.
// Create unread-comment flags.
var commentID = conversationService.addComment( conversation.id, user.id, post.comment );
}
notificationService.sendCommentEmails( commentID, userID );
pushService.pushComment( commentID );
}
}
// =============================================================== //
// =============================================================== //
// The application / coordination / workflow layer for the API
// request. This handles the lower-level security and coordinates
// related events that must take place around posting a comment.
component {
function postComment( userID, conversatinID, comment ) {
var user = userCache.get( userID );
if ( ! user ) {
throw( "NotFound" );
}
var conversation = conversationCache.get( conversationID );
if ( ! conversation ) {
throw( "NotFound" );
}
if ( ! conversationService.canUserPostComment( post.conversationID, user.id ) ) {
throw( "Unauthorized" );
}
transaction {
conversationService.subscribeUser( conversation.id, user.id );
// Post comment.
// Create unread-comment flags.
var commentID = conversationService.addComment( conversation.id, user.id, comment );
}
notificationService.sendCommentEmails( commentID, userID );
pushService.pushComment( commentID );
}
}
</cfscript>
The workflow layer has a lot of steps in it. And, many of these steps are repeated in the two Controllers; but, the workflow layer defers to the Service layer for the fulfillment of the steps.
What do you think? Am I crazy? Am I on the right track? Feedback appreciated.
Want to use code from this post? Check out the license.
Reader Comments
That makes a lot of sense, Ben. It's always a good idea to break your process down that way. Figure out the dependencies and the conditional activities.
I had an instructor during my undergraduate education who taught O.O.P. (with C++). One of the code requirements for his class was that no function could be longer than fifteen lines long (after he found that the ten-line restriction was practically untenable). Breaking up code into such small pieces really helps you think about what belongs together and what doesn't.
If the email being sent to add a comment is in response to an email sent automatically by the comment system, you will probably want to make sure it is not a bounced email, just in case the email address didn't actually exist.
You may not need to check for that depending on how the system is set up. That was just something I thought of as I was reading.
Hi Ben! I think you need to define what your architecture is trying to achieve. e.g. make your code intuitive and maintainable. To me the pseudocode is a bit procedural (to many if statements). You might want to farm off / encapsulate security implementation somewhere else, and also the business rules. This means you can change / enhance / reuse and keep your controllers clean. Also the first time post / subscribe logic could be asynchronous / triggered by another event (e.g. just capture all posts and have an separate process that figures out what to do with them.)
Clever code (or code that does a lot) is a maintenance headache and is therefore dumb. Dumb code (which does very little i.e. cohesive) is actually clever. The art is in tying the bits together.
Agreeing with Paul, I think it is key to think about readability, maintainability, and avoiding code duplication in all parts of development.
Sometimes one needs to break the system into smaller parts to see the commonalities. Where it gets tricky is when you have to sacrifice readability for code optimization.
I really like your approach, I think it is a good balance.
@Darren,
It's certain that I'm still in a very procedural frame of mind. I still have yet to embrace full OO and think more about breaking up tasks so they can be reused to some degree. I do like the idea of abstracting out some of the security into into it's own module, to keep all the security stuff focused in one area. Also, I think that would help it from leaking into other areas of the Application.
That said, a number of IF statements here were concerned with whether or not a given record / entity existed. I suppose I could have those method raise NotFound exceptions - like how EntityLoadByPK() will raise an exception if you ask for something that doesn't exist.
I have gone back and forth on the last point. Sometimes I love the idea of throwing an exception. Sometimes I like the idea of returning void/null. Sometimes I like the idea of returning an empty object.
I have to do more thinking on that one, for sure!
@Paul,
I definitely have some components that are rather beastly - and I think this is a byproduct of poor separation of concerns. I am trying to keep things smaller and more focused these days; but it's hard - and with time-pressures, lots of corners get cut :(
@David M,
That's a really interesting point. In my personal usage of hosted SMTP services (such as for this blog), I am shocked at how many emails bounce. I get a pretty decent number - like 9% bounce. That seems huge to me! Probably would help identify some spam if I tracked that with a bit more detail.
@David D,
Thanks for the positive feedback. I always feel a bit awkward when I see a few conditions repeated; but, at the same time, I feel like each scenario has a different set of rules... SOME of which overlap; so I use that to rationalize / comfort myself that it's not duplication, but two different workflows :)
@Ben, when you say "a number of IF statements here were concerned with whether or not a given record / entity existed." - this is a clue that these statements probably belong somewhere else; some kind of "validator" perhaps (== 'separation of concerns'). Then it doesn't matter so much whether you use nulls or throw errors, as long as calling code can handle it. If you write to a pattern, then you can reuse this validator code in other parts of your application. And maybe make it configurable, so you can swap between throwing errors (dev mode) and just returning nulls. Features are easier to add when code is separated cleanly - if you want to toy with various approaches, as it stands you would quickly get tangled up.
Another benefit of this approach is it simplifies your comment posting code.
Another classic statement you make is this "I think it makes sense that I always want to couple the creation of a comment and the creation of "unread" comment flags." - for all the world this might be true now, but so much of software development is correcting false assumptions. If you consider decoupling these actions you can still have them work together but make it easier to change the logic later - like what if you were only concerned with unread comments from users who had a certain number of kinky points? ;)
@Ben,
You brought up a common problem I've noticed in a lot of web application development, cutting corners. I think it would be interesting if we abstract the different layers of web application development and approach a software engineering perspective to model a process for creating these rich applications. I find the difference between a web application and a simple website sometimes is not understood clearly enough in the clients mind causing unrealistic timelines and cutting cutting corners, as you say, in development.
I think we all have heard Dijkstra's comment that "premature optimization is the root of all evil" and I think that may be the unfortunate consequence of trying to optimize without a plan of action.
So if we can somehow abstract the optimization layer from the development layer and remember to have a project requirements specification, followed by a prototyping stage of development, one could clearly iron out most of the wrinkles. Once one reaches the optimization stage it will be much clearer as to what is duplication and what is a completely different workflow.
@Darren,
In parts of our application, as it evolved, we started using a few "assert" statements. Something like this:
someService.assertConversation( converationID );
... but, there's something that I don't quite like about this. And, I think what that is that it doesn't return a value - it simply throws/raises an exception if the given record doesn't exist.
I think what I would want is a record to be returned AND the exception to be encapsulated... much like the EntityLoadByPK() works - it returns an entity OR raises an error.
@David D,
There is definitely a big gap between a web "application" and a "web site"... most of the time. And, it's a really interesting topic, bringing up premature optimization. Sometimes, I will see people put a TON of infrastructure and boilerplate and framework stuff in place for a most simple of web sites. They end up with like 10,000 lines of code for a web site that has 5 pages and 50 lines of content.
I look at that and think that some people have no idea how to "karate chop" certain tasks. It's like things are all-or-nothing when it comes to complexity.
I probably lean too much to the side of "chop", and don't balance it enough with proper architecture and future-proofing. But, definitely there are tools to be used at different times.
And, I am 100% behind prototyping first. That's exactly why we built InVision - http://www.invisionapp.com . It's alllll about the prototype and collaborative design.