Last active
October 25, 2020 14:30
-
-
Save sander/1cc2d4f6cc3dfa0ee1625198f72ec3ad to your computer and use it in GitHub Desktop.
Event-driven architecture prototyping in vanilla JavaScript
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* The following is a prototype to demonstrate how enterprise integration can be modelled using | |
* vanilla JavaScript. This could benefit service design: low-fidelity modelling of bounded contexts | |
* and messages makes ideas more tangible to explore and communicate. The model can be run inside | |
* an HTML page and tested using: | |
* | |
* dispatchEvent(new Event("test")); | |
* | |
* In this script I will refer to the following design domain concepts. | |
*/ | |
const { | |
c4: { container, component }, | |
domainDrivenDesign: { boundedContext }, | |
enterpriseIntegrationPatterns: { processManager }, | |
enterpriseApplicationArchitecture: { repository, domainEvent, dataTransferObject }, | |
archiMate: { applicationService }, | |
} = { | |
c4: { | |
container: "https://c4model.com/#Abstractions:~:text=team.-,Container,communication.", | |
component: "https://c4model.com/#Abstractions:~:text=communication.-,Component,units.", | |
}, | |
domainDrivenDesign: { | |
boundedContext: "https://martinfowler.com/bliki/BoundedContext.html", | |
}, | |
enterpriseIntegrationPatterns: { | |
processManager: | |
"https://www.enterpriseintegrationpatterns.com/patterns/messaging/ProcessManager.html", | |
}, | |
enterpriseApplicationArchitecture: { | |
repository: "https://martinfowler.com/eaaCatalog/repository.html", | |
domainEvent: "https://martinfowler.com/eaaDev/DomainEvent.html", | |
command: "https://martinfowler.com/bliki/CommandQuerySeparation.html#:~:text=Commands,value.", | |
dataTransferObject: "https://martinfowler.com/eaaCatalog/dataTransferObject.html", | |
}, | |
archiMate: { | |
applicationService: | |
"https://pubs.opengroup.org/architecture/archimate3-doc/chap09.html#_Toc302490429", | |
}, | |
}; | |
/** | |
* Each {@link boundedContext} is implemented by a stateful {@link container}. In this case, | |
* the context of message delivery is contained in a function that closes over message repository | |
* state. It needs to be configured with two dependencies: one for dispatching {@link domainEvent}s | |
* to other contexts, and one for generating unique IDs. | |
*/ | |
const messageDelivery = (messageRepositoryState = { messagesById: {} }) => ({ | |
dispatchDomainEvent, | |
generateUniqueId, | |
}) => { | |
/** | |
* Now we will define multiple {@link component}s. | |
* | |
* The first is an {@link applicationService} for handling message delivery {@link command}s. | |
* These should not return anything of value, but they should dispatch new {@link domainEvent}s. | |
* | |
* Name the {@link command} with an imperative verb and the {@link domainEvent} with a past | |
* perfect tense verb. The {@link domainEvent} is a {@link dataTransferObject} and can carry | |
* payload data. | |
*/ | |
const applicationService = { | |
validateSubmission: ({ messageContent }) => | |
dispatchDomainEvent("SubmissionAccepted", { | |
messageId: generateUniqueId(), | |
messageContent, | |
}), | |
consignContent: ({ messageId }) => dispatchDomainEvent("ContentConsigned", { messageId }), | |
}; | |
/** | |
* The {@link processManager} implements message delivery policies in terms of responses | |
* to observed {@link domainEvent}s. The response is usually formulated as a {@link command}, | |
* possibly influenced by {@link processManager} state. | |
*/ | |
const processManager = ({ type, payload }) => { | |
if (type === "ConsentedToSubmitMessage") applicationService.validateSubmission(payload); | |
else if (type === "SubmissionAccepted") applicationService.consignContent(payload); | |
else if (type === "ContentConsigned") /** @todo implement content handover command */ return; | |
else console.error("Unknown event of type", type); | |
}; | |
/** | |
* The following {@link repository} records message-related {@link domainEvent}s and persists | |
* relevant data into aggregate message state. | |
*/ | |
const messageRepository = { | |
eventHandler: ({ type, payload: { messageId, messageContent } }) => { | |
switch (type) { | |
case "SubmissionAccepted": | |
messageRepositoryState.messagesById[messageId] = { messageContent }; | |
case "SubmissionAccepted": | |
case "ContentConsigned": | |
messageRepositoryState.messagesById[messageId].lastEvent = type; | |
} | |
}, | |
}; | |
/** | |
* Each {@link container} constructor returns a list of its {@link domainEvent} listeners. | |
*/ | |
return [processManager, messageRepository.eventHandler]; | |
}; | |
/** | |
* The {@link boundedContext} of {@link domainEvent} logging is a lot smaller. It contains a single | |
* event handler {@link component}. | |
*/ | |
const logging = () => () => [ | |
({ type, payload }) => | |
console.info("%cDomain event", "color: white; background: blue;", type, payload), | |
]; | |
/** | |
* Configuration is managed inside a {@link container} in itself. It takes advantage of JavaScript's | |
* built-in message queue to enable communication across configured {@link container}s. | |
*/ | |
const configureDeployment = ({ generateUniqueId }) => (containers) => { | |
const tDomainEvent = "DomainEvent"; | |
const dispatchDomainEvent = (type, payload) => | |
setTimeout( | |
() => dispatchEvent(new CustomEvent(tDomainEvent, { detail: { type, payload } })), | |
0 | |
); | |
const listenToDomainEvents = (listener) => | |
addEventListener(tDomainEvent, ({ detail }) => listener(detail)); | |
containers.forEach((boundedContext) => | |
boundedContext({ dispatchDomainEvent, generateUniqueId }).forEach(listenToDomainEvents) | |
); | |
return { dispatchDomainEvent, listenToDomainEvents }; | |
}; | |
/** | |
* We specify acceptable behavior using the available console functions. | |
*/ | |
addEventListener("test", async () => { | |
console.info("Running tests..."); | |
const dependencies = { generateUniqueId: testUtils.generateUniqueId() }; | |
const { dispatchDomainEvent, listenToDomainEvents } = configureDeployment(dependencies)([ | |
logging(), | |
messageDelivery(), | |
]); | |
{ | |
console.group("Feature: Message delivery"); | |
{ | |
console.group("Scenario: Automatic content consignment after consenting to submit a message"); | |
console.info("Given I have prepared a message"); | |
const messageContent = { | |
sender: "example:users:d56223e1-ff34-4caa-8335-e0c3e3553073", | |
recipient: "example:users:926f3b86-ed9c-42ac-9acb-1ce65f2e31cc", | |
parts: [{ contentType: "text/plain", content: "Hello" }], | |
}; | |
console.info("When I consent to submit that message"); | |
dispatchDomainEvent("ConsentedToSubmitMessage", { messageContent }); | |
console.info("Then the submission gets accepted"); | |
console.assert( | |
await testUtils.expect(listenToDomainEvents)({ | |
domainEventType: "SubmissionAccepted", | |
withinMilliseconds: 1000, | |
}), | |
"No submission acceptance occurred in time." | |
); | |
console.info("And the content gets consigned"); | |
console.assert( | |
await testUtils.expect(listenToDomainEvents)({ | |
domainEventType: "ContentConsigned", | |
withinMilliseconds: 1000, | |
}), | |
"No content consignment occurred in time." | |
); | |
console.groupEnd(); | |
} | |
console.groupEnd(); | |
} | |
console.info("Tests finished"); | |
}); | |
const testUtils = { | |
generateUniqueId: (state = { lastUniqueId: 0 }) => () => { | |
return state.lastUniqueId++; | |
}, | |
timeout: (ms) => new Promise((resolve) => setTimeout(() => resolve(), ms)), | |
firstDomainEvent: (listenToDomainEvents) => (expectedType) => | |
new Promise((resolve) => { | |
listenToDomainEvents(({ type, payload }) => | |
type === expectedType ? resolve(payload) : null | |
); | |
}), | |
expect: (listenToDomainEvents) => ({ domainEventType, withinMilliseconds }) => | |
Promise.race([ | |
testUtils | |
.firstDomainEvent(listenToDomainEvents)(domainEventType) | |
.then(() => true), | |
testUtils.timeout(withinMilliseconds).then(() => false), | |
]), | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<title>Test suite</title> | |
<meta charset="utf-8" /> | |
<link rel="icon" href="data:;base64,iVBORw0KGgo=" /> | |
<body> | |
<script src="model.js"></script> | |
<script> | |
dispatchEvent(new Event("test")); | |
</script> | |
</body> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example test run: