on software

Under the Hood of Spense.app: The Code.

This article is a translation and adaptation of my article in Russian.

While Spense v0.2 is under development, I want to talk about the internal organization of the application from a technical perspective. This article is mainly for web developers, and it's written in the corresponding language, so if you're reading and don't understand anything, that's okay, you can just skip it.

In a Nutshell

Backend on Django (Python), frontend on Django templates and Bootstrap, with a pinch of JavaScript and some htmx (not anymore).

Why So Boring?

Sounds not very hype, right. But remember that Spense in its current state is not a full-fledged product. It's more of a prototype, in which I often need to change things and test ideas. If the ideas work, I'll throw this code away and write another; if they don't, I'll just keep it for memory.

So, I chose Django not because I love it (actually, I hate it), but because I've gotten used to it over the last year, and it allows me to prototype the application easily and quickly.

In terms of backend architecture, there's nothing to brag about yet, everything is just as it usually is in Django projects. The code is divided into three Django apps:

  • history - for DB models and a bit of logic about wallets and transactions;
  • tags - for DB models and a bit of logic around tags;
  • app - main views with lots of logic inside, HTML templates, and static files.

Initially, this division made sense to me, but now I'm rather suffering from it because I still can't remember where which models are located. Moving models between Django apps is a separate headache, so I continue to suffer.

The frontend is even more boring. The last time I wrote a somewhat complex interface on React was in 2017, and after that, I rarely touched modern JS frameworks. Simple HTML was enough for me in most cases, and jQuery, like a faithful old dog, always came to the rescue. Here, though, I decided not to roll out jQuery yet, using "vanilla JS" instead, but in practice, it looks very similar.

For example, here's a snippet about loading "smart tags" - expense categories that Spense tries to guess based on the entered amount:

{% block script %}
    const $amount = document.getElementById('amount');
    const $wallet = document.getElementById('wallet');
    const $add = document.getElementById('add');
    const $listSmartTags = document.getElementsByClassName('smart-tags-list')[0];
    /* ... */

    $amount.addEventListener('keyup', function (e) {
      if (e.target.value) {
      } else {
        $add.setAttribute("disabled", "")
        $listSmartTags.innerHTML = '';

    /* ... */

    function updateSmartTags() {
      const walletId = $wallet.value;
      const amount = $amount.value;

      fetch(`{% url 'smart_tags' %}?wallet_id=${walletId}&amount=${amount}`)
        .then(res => res.json())
        .then(/* ... */)
        /* ... */
{% endblock %}
The {% url %} tag mixed with interpolated JS variables - 🤌

As you can see, everything is quite straightforward: I leave scripts right in the <script> tag on the page, and inside it's almost like in jQuery, even the $ prefix is there to distinguish DOM elements from other identifiers.

As for Bootstrap - for now, it's just my default choice. Tailwind seems to be gaining momentum, but I'm not yet ready to get used to it. Even though my HTML also occasionally has class="row border-top border-black border-opacity-10 pt-1" or just inline styles in the style attribute, I try to move them to a separate CSS class if there are many of them. Tailwind officially recommends multi-cursor editing for such cases, and I don't even know how to comment on that.

Working with the Code

At the basic level, everything is trivial: one git repository, hosted on GitHub within my "organization".

Several pre-commit hooks are set up in the repository, including ruff --fix and ruff format (I've almost everywhere moved from flake8+black to ruff because it's a very nifty Swiss Army knife).

At the very beginning, by the way, there were neither hooks nor code formatting, I just wrote and committed as is. Like, why do I need some code style if I'm developing this alone, and I don't have to argue with anyone about indents and brackets?

But last year, I realized that this creates some difficulties, and it's necessary to at least have basic code checking and clean up unused imports. And at the same time, "groom" the code itself, because I have nothing against the Black code style, and I even like it.

So, at some point, I formatted everything, fixed it, added hooks, and used .git-blame-ignore-revs so that git blame would ignore these commits.


For tests, I use pytest + pytest-django, but there are very few tests right now. Mostly, it turns out that all features are somehow related to the interface, and I just check them during development. And since I'm also an active user, I automatically become a manual tester for most features. Of course, the code often covers only the happy path, with minimal validation of input data, but I don't need more for now.


Poetry is currently responsible for Python dependencies. Initially, it was just pip install -r requirements.txt when there were about 5 dependencies that I hadn't updated for two years. But pretty soon, it became clear that poetry+pyproject.toml is much nicer and more convenient.

To keep dependencies up to date, I set up the Renovate bot, which finds new versions for everything I have in the repository. Renovate has at least two significant differences from dependabot:

  • it can bundle minor updates into one pull request,
  • and it can merge it itself (not immediately though).

So, in most cases, no action is required from me, unless the CI on this pull request breaks.


I've already written that I manage the project in GitHub Projects, but in addition, I decided to also record significant changes in CHANGELOG.md. For example, the change list for version 0.1 looks like this:

By the way, there are many different tools for maintaining changelogs. I tried scriv, and the process of generating a changelog initially seemed pleasant to me.

Suppose, for the next version of the project, a dozen features are planned, which are developed in different branches, merged into master/main, then at some point, the project is tagged with a new version, and a release is created. Scriv allows you to create separate files in the changelog.d directory during the development process, a piece of the changelog for each feature. Each piece can contain "Added", "Removed", "Changed" and "Fixed" sections. And when it's time to make a release, we run scriv collect, which beautifully merges these pieces into one, takes the new version number from pyproject.toml, and adds everything together to CHANGELOG.md. This is how the list for Spense 0.1 was generated in the screenshot above.

This approach has significant advantages when several developers work in parallel in different branches, and merge conflicts in the changelog are excluded. But after my release, I saw a big disadvantage for myself: from the CHANGELOG.md line, you can't "jump" to the commit or pull request in which the mentioned changes were implemented, you can only look at the commit with the changelog itself:

git blame is helpless

So, I decided to just edit CHANGELOG.md in the course of development, so for version 0.2 and beyond, the changelog combined with the repository history will have a dual benefit: it will tell about the essence of the changes and point to the changes themselves.

git blame is happy

In General

As you can see, nothing supernatural, but at the same time everything is organized in such a way that development has some pace. I have a lot of ideas and only a couple of evenings a week and weekends, so now it is important not to focus on details that are not important at all, but also not to bring the current code to an unsupported state.

Of all the problems, I'm most concerned about the lack of tests right now because I already feel that new changes will soon start breaking the current functionality. So, I need to cover at least the backend part with tests by about 80%, so I catch "Internal Server Error" less often after shopping.

And this is still not the whole story about what's currently "under the hood", so I'll write more about CI, infrastructure, how I sometimes involve AI for code reviews, how everything recently crashed, and something else.

Stay tuned!