Coming soon: I'm updating the text editor/definition maker feature of this codebase to dotnet 9 and Angular 19 and will make it available as a stand alone application.
This is a personal project that came out of my work as a language teacher. The application allows teachers to take a text, adjust it to a specific vocabulary level, and then collect data on unknown words in the text while automatically creating targeted learning activities for each student.
As a SAAS product, it never gained traction as it was challenging to identify a customer/purchasing decision maker in an educational system for a small, specialized platform like this. The vocabulary profiler as a free service has been successful and has had, for several years, a global user base of about 5000 unique yearly visitors. Currently I maintain the free version of the site with hosting paid by an educational institution in the UAE.
I've made this repository public to showcase the programming work I did on the biggest version of the application. Overall, I rewrote vocabkitchen.com four times from 2012 to 2024: from .NET Webforms to .NET MVC/AngularJS to .NET Core 3.1/Angular 9 with a final downsizing rewrite in .NET 8 and React 18. Unfortunately, most of what I describe below was removed from the site in the last rewrite so a demo isn't publicly available.
Here is a tour of some of the highlights of how this application was built at its peak complexity, which is captured in this repo.
The monolithic .NET application contains four projects:
VkCore: contains domain interfaces and classes. This project is "clean" in that it has very minimal depedencies on external packages and is a pure object-oriented representation of the business domain. I did make the exception that the EF DbContext
is injected into methods that need to do data manipulation.
VkInfrastructure: handles I/O behavior like reading from the file system, sending email, third party API access, and database access
VkWeb: a gateway to the infrastructure and domain logic via an API. It also bundles and serves the Angular client application.
VkWeb/ClientApp: an Angular 9 application that provides a GUI over the VkWeb API
The application uses domain driven design to model the behavior of teaching new vocabulary words from a text to a group of students. At the heart of this domain there is a reading (a text), words or phrases in the reading which students can understand (or not), and word definitions to help students gain understanding of the word's meaning, pronunciation and spelling.
The heart of this design is the Reading class. This was loosely inspired by some homework I did on how Google Docs is built. A Reading
consists of a collection of ContentItems (in hindsight, that could've had a better name like ReadingSection
or something). A Reading
is text broken up into a collection of strings (ContentItems
) each with a index that, when sorted in order, allows the Reading
to output the complete text body from its parts.
For example, here is a text:
Text: "He is sad."
Index: 0123456789
To model this as a Reading
you could have a single content area starting at index 0 with the value
of "He is sad." Then, to define "sad", you would need to split the text into 3 ContentItems
. The first would start at index 0 and contain "He is ", the second has index 6 and contains "sad", and the last has index 9 and contains ".". Once we've split out the ContentItems
, we're then able to attach definitions to them.
As a user edits the text in a <textarea />
and adds definitions, the ContentItems
need to shift their indexes, or split and collapse ContentItems
. The methods on Reading
handle this behavior, and in the spirit of tests as documentation, you can see more about how this works in the unit tests in ReadingShould.cs.
I should note that the client-side implementation of this was a custom-built text editor in Angular VkWeb/ClientApp/src/app/org-dashboard/reading-edit/reading-edit.component.ts where a ContentItem
roughly maps to an Edit
VkWeb/ClientApp/src/app/org-dashboard/models/edit.ts. I won't go into detail here, but that Angular component also handles undo/redo and captures the index of all edits made by the cursor/keyboard so they can be captured appropriately by the backend model and is a good example of using RxJS and event listeners to capture keyboard and mouse input.
For now, that is a solid example of how a domain problem (editing a text and adding word defintions) was modeled using C# classes. I'll walk through the run-time behavior of this code in the next section.
The application uses the mediator pattern to expose the domain logic and infrastructure defined in the project via an API. I found this pattern to be slightly more functional than the sometimes bloated service classes you find in n-tier .NET applications, for example, you could imagine a ReadingService
class instead of the Reading
domain model and request handlers. I find this pattern also reduces the temptation to write god objects in a such a service class, and forces you to think in terms of single-responsibility. One trade off is that tracing run-time behavior orchestration can be more complicated. In this application I use service classes to perform specific tasks, for example, pulling a complete sentence from a Reading
at a specific index ExampleSentenceService.cs, and not as a catch-all for business logic.
So instead of a structure like
api controller -> dto object -> service class -> big service method that does all the things
we have
api controller -> request object -> request handler -> domain model -> any events as needed -> event handlers as needed
An straightforward but interesting workflow to demonstrate the mediator pattern is adding a definition to a text. The requirements were:
- given word polysemy, the definition needed to be connected a specific word definition at a specific index range in a text
- we should capture the sentence that contained the word or phrase from the text so we can use it in learning exercises
- we need to generate an audio file of the pronunciation if we don't already have one.
Given the domain models (Reading
and ContentItem
) from the above section, this is how adding a definition would flow through the application.
- client-side: The user chooses some definition values in definition-modal.component.ts. These values are posted back to the API via the Angular
ReadingService
- api controller: The values from the client come into the API ReadingController as an
AddDefinitionRequest
. The mediator pattern leads to very thin controller actions, and all we do here is grab the user id from the request context and pass the data off toMediatR
. - request handler:
MediatR
matches our request to the AddDefinitionRequestHandler. The request handlers here do several jobs: 1) do data validation with guard clauses as needed, 2) pull back any data needed to hydrate the domain model. In this case we use the builder pattern - VkCore/Builders/DefinitionBuilder.cs - to construct our definition object - domain model: we finally pass off to
Reading.InsertDefinition
on our domain model to persist the new definition. - events: An interesting side effect to note here, part of our domain model is a WordEntry, which is the persisted, dictionary entry equivalent of a
ContentItem
. So when we add a definition to a content item, for example with the value "sad", we also create a "sad"Word
entry if needed: this is the part of the domain that students study over time. In this case, theAddDefinitionRequestHandler
conditionally calls theWordEntry
domain model, which in its constructor fires off a WordAddedEvent - event handler: A nice feature of the event pattern in mediator is the ability to write multiple single-responsibility handlers to respond to the event, reducing the need to write orchestration code. In this case, the
WordAddedAudioCreationHandler
event fires, sending off aCreateWordAudioRequest
, which fires the CreateWordAudioRequestHandler that calls an AWS lambda which asychronously calls a text to speech API and dumps an audio file in an S3 bucket which the client application can use later to play word audio.