Skip to content

Instantly share code, notes, and snippets.

@sander
Last active October 25, 2020 14:30
Show Gist options
  • Save sander/1cc2d4f6cc3dfa0ee1625198f72ec3ad to your computer and use it in GitHub Desktop.
Save sander/1cc2d4f6cc3dfa0ee1625198f72ec3ad to your computer and use it in GitHub Desktop.
Event-driven architecture prototyping in vanilla JavaScript
/**
* 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),
]),
};
<!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>
@sander
Copy link
Author

sander commented Oct 25, 2020

Example test run:
Schermafbeelding 2020-10-25 om 15 28 56

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment