I've been integrating a system I've been developing with a third party service for data synchronisation. We're looking to synchronise tasks out with systems like Pivotal Tracker, Jira, Trello etc, but were unsure which of those systems we would actually use. I'm currently reading Clean Code, and there's a really interesting and relevant chapter on "Boundaries". It covers a similar scenario to what we were facing: Using Third-Party Code.
Generally, you should wrap any third party code that you're dependent on with your own interfaces for that code. That way you get to define how your main application logic interfaces with the third party code, rather than having to have to bend your application to conform to a third party library, API, or applications structure. Isolating the complexities of dealing with third parties within an application to a particular class or module behind an interface that you control also allows you to:
If we were building direct integration (with a project management tool like Jira, Pivotal Tracker, Trello) into our application, the models and interfaces of the third party system would leak into our core system.
Our application has support for Projects, Stories and Epics. Pivotal Tracker models these entities as well, but the relationship between Stories and Epics is based on a labelId assigned to the Epic at Pivotal. To attach a story to an epic we actually attach it to the label of the epic. Pivotal Tracker also handles story creation with labels/epics differently from story updates with labels/epics. Epics were introduced with v5 of the Tracker API. Previously, they didn't exist as a concept.
const saveStoryCallback = (response, error) => {
if error {
handleError(error)
return
}
const story = response.data
// Perform other post-save actions (eg, update aggregated or count fields)
...
// Sync with Pivotal
let labelId = null
if story.epic {
const epicModel = story.epic.syncData
if epicModel {
labelId = pivotal.getEpic(epicModel.id).labelId
} else {
data = pivotal.createEpic(story.epic.pivotalModel)
story.epic.syncData = data
labelId = data.labelId
}
}
const storyModel = story.syncData
if storyModel && pivotal.getStory(storyModel.id) {
pivotal.updateStory(story.pivotalModel)
} else {
story.syncData = pivotal.createStory(story.pivotalModel)
}
if (labelId) {
pivotal.attachLabel(story, labelId)
}
}
Our save story callback will need to deal with the intricacies of the external system/library. If we wanted to swap this out for another tool (e.g. Trello) we'd need to completely rewrite the logic in our save story callback, which is intermingled with core application logic. If we wanted to model the same synchronisation process with Trello, the logic would be different because the external model is different. We'd model epics as a Board, and stories as a Card.
To avoid changes to our core application logic, we need to isolate the synchronisation logic behind an interface that represents entities in the language of our application, not the third party application.
const getStory = (story) => { ... }
const syncStory = (story) => { ... }
const deleteStory = (story) => { ... }
const syncProject = (project) => { ... }
const deleteProject = (project) => { ... }
const syncEpic = (epic) => { ... }
const deleteEpic = (epic) => { ... }
Then, we need to implement an adapter to interface with one of the third parties. Lets say we're interacting with Pivotal again:
import { syncEpic, syncStory } from "sync-adapter"
const saveStoryCallback = (response, error) => {
if error {
handleError(error)
return
}
const story = response.data
// Perform other post-save actions (eg, update aggregated or count fields)
...
syncEpic(story.epic)
syncStory(story)
}
Then in our sync-module:
const syncEpic = (epic) => {
if epic {
const epicModel = epic.syncData
if epicModel {
labelId = pivotal.getEpic(epicModel.id).labelId
} else {
data = pivotal.createEpic(epic.pivotalModel)
epic.syncData = data
}
}
}
If we need to swap out Pivotal for Trello, we can simply replace the contents of the sync-adapter
with the implementation for the different provider. The core application callback won't have to change if the interface is isolated in this way.
Let's say in the future we have a requirement to support more than one external system. The existing isolation model between the callback and the sync-module still applies. We'd just need to inject another adapter in the middle of this flow.
sync-adapter
to represent the specific external system it relates to: pivotal-adapter
.trello-adapter
sync-adapter
that will interface with both of these modules.import { syncEpic as syncPivotalEpic } from 'pivotal-adapter'
import { syncEpic as syncTrelloEpic } from 'trello-adapter'
const syncEpic = (epic) => {
const syncData = epic.syncData
if syncData.service == 'pivotal' {
syncPivotalEpic(epic)
} else if syncData.service == 'trello' {
syncTrelloEpic(epic)
}
}
Addition of more third parties can take place in the future without further changes to the core application logic. All the changes are pushed out to the boundaries of the sytems.
If a third party changes their interface, or even their domain model, all changes will be isolated to the integration module alone. Your core application flow should be unaffected by the change, as the interface it interacts with should remain stable.
When Pivotal changed their API from v3 to v5, they introduced the concept of the Epic. Previously our application would have had epics internally, but the adapter would have converted epics to labels to support Pivotals model. With changes for their v5 API, Epics become a first class citizen as far as they are concerned and we'd update the adapter, but our application core application would not need to change.
Isolating third party code via adapters provides benefits in terms of abstracting logic and complexity out of your main application flow. Your application will communicate with the third parties in a consistent manner via the interface, and not be forced to change if a change appears. It ensures that any future conceptual, model, flow/logic and interface changes in the third party only impact the code that deals with that third party. It gives you the ability to swap out your external dependencies with little to no impact on your core applications, as well inject new functionality.