I Built a Full SaaS Finance App Solo Using AI — Here’s My Honest Take on Every Model I Used

I’m a DevOps engineer. Not a “real” fullstack developer by training. But over the past year I’ve been building YourFinanceWORKS — a self-hosted, AI-powered finance platform with invoicing, OCR receipt processing, bank reconciliation, multi-tenant architecture, MCP integration, and a full business intelligence layer.

Python/FastAPI backend. React + TypeScript + Vite frontend. 670+ commits. One person.

AI made this possible. And I went through a lot of models to get here.

This is my honest, unsponsored breakdown of every model I used — what worked, what didn’t, and what surprised me.

🧪 Phase 1: The Free Model Rotation

Before paying for anything, I treated free models like a buffet. Here’s what I found:

Windsurf SWE-1.5

Genuinely the best free option for coding tasks. Fast responses, decent output quality, and it handles real engineering problems — not just toy examples. I’ve half-jokingly wondered if Windsurf has some Google collaboration going on given how polished it feels for a free tier.

If you’re a developer on a budget, start here.

Grok Code

Worth keeping in your rotation. Solid for dev tasks and it’s free. Not my first call for complex refactoring, but for quick code generation and boilerplate it holds its own.

Ollama (Local Models)

I really wanted this to work. The appeal of running everything locally — privacy, no API costs, full control — is real. And the output quality was okay. But the speed killed my flow completely. When you’re deep in debugging a multi-tenant isolation issue, waiting 30+ seconds per response breaks the mental thread entirely. Local models will get there, but they’re not there yet for serious daily use on consumer hardware.

Free OCR Models

This one matters a lot for YourFinanceWORKS since OCR is central to the receipt and invoice processing features. My honest experience: roughly 80% accuracy. That sounds acceptable until you’re reconciling actual financial data and realize that 20% error rate means manual correction on 1 in 5 documents. Not viable at scale.

For comparison, Claude Haiku — Anthropic’s smallest paid model — made noticeably fewer mistakes. Not zero, but the gap was meaningful.

Gemini Free Tier + Mistral Le Chat

Both solid for research, long-context reading, and general questions. Mistral is underrated for reasoning tasks. Neither replaced a proper coding assistant for me, but they filled gaps well.

📅 Phase 2: Gemini Advanced (A Couple of Months)

I paid for Gemini Advanced for a couple of months and it had genuine strengths.

Where it excelled: UI work. Gemini has a strong instinct for layout and component design. For the React/TypeScript frontend — building out dashboards, data tables, form flows — it consistently produced clean, well-structured components. If your work is frontend-heavy, Gemini is worth serious consideration.

Where it fell short (for me): Deep backend logic. When I was debugging gnarly FastAPI issues, refactoring complex multi-tenant database isolation, or reasoning through Kafka event flow — the responses felt like they understood the surface of the problem but not the intent behind it. I kept having to over-explain context that a more senior-feeling model would have inferred.

Good product. Just not the right fit for the kind of work I was doing most.

✅ Phase 3: Claude Pro (This Month — Where I Landed)

This is where bug fixing and refactoring finally felt right.

The difference I kept noticing: Claude understands the intent behind code, not just the syntax. When I’m tracking down a subtle multi-tenant isolation bug, it doesn’t just look at the function I paste — it reasons about what I’m trying to do and where the breakdown is likely happening. When I ask for a refactor, it asks clarifying questions that show it understood the architecture, not just the snippet.

For a project like YourFinanceWORKS — with real financial logic, security-sensitive multi-tenant patterns, and a lot of interconnected moving parts — that difference matters a lot.

Is it perfect? No. It makes mistakes. It sometimes over-explains. But for the specific task of “help me fix this real bug in a real codebase,” it’s the best I’ve used.

🤔 The Rate Limit: An Unpopular Opinion

Here’s something I didn’t expect to say: Claude’s rate limit has been good for me.

I was using AI as a pure reflex. Every half-formed thought, every minor question, every moment of uncertainty — I’d just fire it off immediately without thinking first. It became a dependency loop. The question would pop into my head and I’d be typing before I even finished forming the thought.

The rate limit broke that loop.

Suddenly I had to triage. Is this question actually worth using a message on? Can I think through this myself first? Can I look it up? The friction forced me back into the habit of actually thinking — and I started retaining more because I wasn’t just offloading everything.

Long term, I genuinely believe the rate limit is helping me avoid a real AI dependency problem. I came to Claude a bit addicted to the instant-answer reflex that unlimited free models had built in me. The constraint is doing what I couldn’t do on my own: making me more intentional.

I know that’s not a popular take. But it’s been my experience.

🗺️ My Model-to-Task Map (After All This)

Task My Go-To
Bug fixing & refactoring Claude (not close)
UI / frontend components Gemini
Quick code generation Windsurf SWE-1.5 or Grok Code
Local / private work Ollama (if speed isn’t critical)
OCR at scale Don’t cheap out — the accuracy gap is real
Research & long-context Gemini or Mistral

💭 The Bigger Question

The README for YourFinanceWORKS ends with a question I’ve been sitting with:

Is the SaaS business model really dying? With AI drastically lowering the barrier to building sophisticated software, what can one person with DevOps skills and AI assistance actually ship?

I don’t have a clean answer yet. But I built a multi-tenant finance SaaS with OCR, banking reconciliation, MCP integration, and business intelligence — solo, while working a day job — and it runs in production.

That used to require a team. Now it requires the right AI stack and the discipline to use it well.

The project is open source (AGPL-3.0 for the core): github.com/snowsky/yourfinanceworks. Happy to discuss the architecture, the AI workflow, or anything else in the comments.

JavaScript DOM Explained: Concepts, Examples, and Interview Questions

🌐 Complete Guide to DOM in JavaScript (Beginner to Advanced)

📌 Introduction

In modern web development, creating interactive and dynamic web pages is essential. This is where the Document Object Model (DOM) plays a crucial role. The DOM acts as a bridge between HTML and JavaScript, allowing developers to control and manipulate web pages programmatically.

This blog provides a complete understanding of the DOM, starting from basic definitions to advanced concepts, along with interview questions and answers.

🧠 What is DOM?

📖 Definition

The Document Object Model (DOM) is a programming interface for web documents. It represents the structure of an HTML or XML document as a tree of objects, where each object corresponds to a part of the document.

JavaScript uses the DOM to:

  • Access HTML elements
  • Modify content
  • Change styles
  • Handle events
  • Create dynamic web pages

💡 Simple Explanation

Think of a webpage as a tree structure:

  • The entire webpage = Root node (document)
  • HTML tags = Elements (nodes)
  • Content inside elements = Text nodes

This structure allows JavaScript to easily navigate and update the page.

🌳 Example of DOM Structure

<!DOCTYPE html>
<html>
  <head>
    <title>My Page</title>
  </head>
  <body>
    <h1>Hello World</h1>
    <p>This is a paragraph</p>
  </body>
</html>

DOM Tree Representation:

Document
 └── html
     ├── head
     │   └── title
     └── body
         ├── h1
         └── p

⚙️ DOM Manipulation in JavaScript

🔍 Selecting Elements

JavaScript provides multiple ways to select elements:

document.getElementById("id")
document.getElementsByClassName("class")
document.getElementsByTagName("tag")
document.querySelector("selector")
document.querySelectorAll("selector")

Example:

let heading = document.getElementById("title");

✏️ Changing Content

document.getElementById("demo").innerHTML = "Hello JavaScript";

Explanation:

  • innerHTML allows inserting HTML content
  • It replaces existing content

🎨 Changing Styles

document.getElementById("demo").style.color = "blue";
document.getElementById("demo").style.fontSize = "20px";

➕ Creating Elements

let newPara = document.createElement("p");
newPara.textContent = "This is a new paragraph";
document.body.appendChild(newPara);

❌ Removing Elements

let element = document.getElementById("demo");
element.remove();

🔄 Replacing Elements

let newElement = document.createElement("h2");
newElement.textContent = "New Heading";

let oldElement = document.getElementById("old");
oldElement.replaceWith(newElement);

🎯 Event Handling in DOM

Events allow interaction between user and webpage.

📌 Common Events

  • click
  • mouseover
  • mouseout
  • keydown
  • submit

🧪 Example

document.getElementById("btn").addEventListener("click", function() {
  alert("Button Clicked!");
});

🔥 Event Types

1. Event Bubbling

Event starts from child and moves upward.

2. Event Capturing

Event starts from parent and moves downward.

⚡ Event Delegation

Instead of adding multiple event listeners, we add one listener to the parent.

document.getElementById("parent").addEventListener("click", function(e) {
  console.log(e.target);
});

🧱 Types of DOM Nodes

  1. Element Node → <div>, <p>
  2. Text Node → Text inside elements
  3. Attribute Node → id, class
  4. Comment Node → Comments in HTML

🔑 Important DOM Properties

  • innerHTML
  • textContent
  • innerText
  • style
  • value
  • parentNode
  • childNodes
  • firstChild
  • lastChild

🔍 DOM Navigation

let parent = element.parentNode;
let children = element.childNodes;
let first = element.firstChild;
let last = element.lastChild;

⚖️ innerHTML vs textContent vs innerText

Property Description
innerHTML Returns HTML content
textContent Returns all text
innerText Returns visible text

⚡ DOM vs BOM

DOM BOM
Deals with HTML Deals with browser
document object window object
Page content Browser features

🚀 Advantages of DOM

  • Makes web pages dynamic
  • Easy to manipulate HTML
  • Supports event handling
  • Platform independent
  • Works with all browsers

⚠️ Disadvantages of DOM

  • Performance issues for large pages
  • Complex for beginners
  • Memory usage can increase

❓ Interview Questions and Answers

1. What is DOM?

DOM is a tree-like representation of HTML that allows JavaScript to manipulate web pages.

2. What is the difference between innerHTML and textContent?

  • innerHTML → includes HTML tags
  • textContent → only text

3. What is querySelector?

It selects the first matching element using CSS selectors.

4. What is addEventListener?

It attaches an event handler to an element without overwriting existing handlers.

5. What is Event Bubbling?

Event propagates from child to parent.

6. What is Event Delegation?

Using a parent element to handle events for child elements.

7. Difference between getElementById and querySelector

  • getElementById → fast, only ID
  • querySelector → flexible, supports CSS

8. What is createElement?

Used to create new HTML elements dynamically.

9. What is remove()?

Removes an element from the DOM.

10. What is DOM Manipulation?

Changing structure, content, or style using JavaScript.

🧪 Real-World Example

<button id="btn">Click Me</button>
<p id="text">Hello</p>

<script>
document.getElementById("btn").addEventListener("click", function() {
  document.getElementById("text").innerHTML = "Changed!";
});
</script>

📚 Best Practices

  • Use querySelector for flexibility
  • Avoid excessive DOM manipulation
  • Use event delegation for performance
  • Keep code clean and readable

🏁 Conclusion

The DOM is one of the most important concepts in JavaScript. It allows developers to create dynamic, interactive, and user-friendly web applications. Understanding DOM deeply will help you become a strong front-end developer.

Mastering DOM means you can:

  • Build interactive websites
  • Handle user input efficiently
  • Create modern web applications

✨ Final Tip: Practice DOM manipulation daily with small projects like:

  • To-do list
  • Calculator
  • Form validation

This will strengthen your understanding and confidence.

SurrealMX: In-memory storage with time travel and persistent storage

Author: Dave MacLeod

SurrealMX: SurrealDB’s new in-memory storage backend

There have been quite a few blog posts since the release of SurrealDB 3.0 to introduce each of the new features we have to show. Today’s post is about one called SurrealMX that you likely have yet to hear about – but use every day.

That’s because the way you use it is…by typing surreal start. That’s it!

That’s because SurrealMX is in fact the name of SurrealDB’s new in-memory datastore. And since SurrealDB stores data in memory by default, 100% of SurrealDB’s users have already given it a try.

What is SurrealMX?

SurrealMX is SurrealDB’s in-memory storage engine that was built from the ground up a bit over a year ago. It was merged into the core database during the 3.0 alpha period last November with no fanfare at all in order to try it out for the first time as SurrealDB’s in-memory storage engine. Since SurrealDB uses in-memory storage by default, that means that SurrealMX has been every 3.x user’s default storage option since then.

But SurrealMX isn’t just a more performant way to store data in memory, it comes with new optional functionality too.

If you pay close attention to the SurrealDB source code you might have noticed a PR merged three weeks ago in the runup to 3.0 general availability that included a new way to pass in configuration parameters when starting a database.

If you go to this part of the documentation for the surreal start command, you can see a comparison of how this worked in 2.x versions compared to 3.0.

In 2.x versions, specifying whether SurrealKV is versioned or not was done before the path to the database which led to either writing surrealkv or surrealkv+versioned. Other backends didn’t have similar configurations.

surrealkv://my_db
rocksdb://my_db
surrealkv://mydb
surrealkv+versioned://mydb
memory

In 3.0, this has now been changed to pass as many configurations as needed after the path to the database, in the same way that you do the same with a URL. Here are two examples from the documentation showing how this is done.

"surrealkv://path/to/db?versioned=true&sync=every&retention=30d"
"mem://tmp/data?versioned=true&aol=sync&snapshot=60s&sync=5s"
"mem://?versioned=true"

Did you notice that versioned=true is found not just in the surrealkv but also the mem backend? That’s because SurrealMX includes versioned queries in the same way that SurrealKV does.

Let’s take a closer look!

Time travel using the VERSION clause

With the addition of SurrealMX, SurrealDB now has two storage backgrounds that have versioning: SurrealKV and memory. Versioning is the capability to not just query a table as it stands now, but as it stood before. This feature is exceptionally important in areas like legal compliance in which a database needs to keep past information intact even if it has been overwritten later on.

In SurrealDB, time travel queries are done using the VERSION keyword. Let’s give this a quick try with the in-memory backend. First we will start a server with ?versioned=true added:

surreal start --user root --pass secret "mem://?versioned=true"

And then connect via Surrealist or this command in the CLI.

surreal sql --user root --pass secret

Once that is done, we can create a person record or two and make some queries using VERSION followed by time::now() minus how ever long ago we would like to query.

CREATE person;
[{ id: person:g4z4v7yoju9pe21jck6v }]

SELECT * FROM person;
[{ id: person:g4z4v7yoju9pe21jck6v }]

// No 'person' records existed yesterday
SELECT * FROM person VERSION time::now() - 1d
[]

// Add a second record
CREATE person;
[[{ id: person:aczudhy0mvef5bxurcry }]]

// 'person' has two records, but about ten seconds ago
// it had just one
SELECT * FROM person VERSION time::now() - 10s
[{ id: person:g4z4v7yoju9pe21jck6v }]

Since the VERSION keyword has been available via SurrealKV since SurrealDB 2.0, it’s very likely that you are already quite familiar with it. Let’s now move on to in-memory persistent storage options which were entirely unavailable before SurrealDB 3.0.

In-memory with optional persistent storage and versioning

In-memory storage with optional persistent storage is one of the features of Redis that has made it so popular. SurrealMX offers this as well, via a somewhat more composable model.

Persistence for the memory backend is used for the following:

  • Append-Only Log (AOL) – Synchronous/asynchronous modes for durability
  • Snapshots – Periodic full database state capture
  • Data Recovery – Automatic recovery from snapshots + AOL on startup
  • AOL Truncation – Automatic cleanup after snapshots

You may be curious at this point how this differs from using SurrealKV (or RocksDB) to store your data. The difference is that SurrealMX persistent storage does not compress the data. It is an append-only-log of database writes, never compacted, until a snapshot occurs. It is in effect an entire database export. You can think of it as a convenience on top of in-memory speed while SurrealKV is used for traditional durable storage for datasets of any size.

Trying it out

Let’s grab part of the documentation to see how it works. These are the available parameters to use when starting a database with in-memory storage.

  • retention (a duration string, e.g. 30d, 24h, 30m)
  • aol (never, sync, or async), for writing changes to an append-only log file
  • snapshot (a duration string, e.g. 60s), for periodically writing a snapshot of the database to the file system
  • sync (never, every, or a duration string like 5s), for specifying when to flush the append-only log file to the file system
  • never – (default) leave flushing to the OS (least durable)
  • every – sync on every commit (most durable)
  • interval – periodic background flushing at the given interval

We can match this up against the example above which shows that aol is set to sync, snapshot is set to 60s, and sync is set to 5s.

"mem://tmp/data?versioned=true&aol=sync&snapshot=60s&sync=5s"

That’s quite a bit to think about, but fortunately the SurrealMX repo has a table that lays out exactly when you would want to use one configuration over another. Each of these is balanced against the performance hit, ranging from no persistence at all (the fastest) to Sync AOL + Every fsync which survives both process and system crash at the greatest cost to performance.

To make a choice, just ask yourself the following as you move down the chart: what do you want to happen to your data if the process or system crashes? Then choose the most performant option that offers you the guarantees you need.

Configuration Survives Process Crash Survives System Crash Performance
No persistence ❌ ❌ Fastest
Snapshot-only ⚠️ (last snapshot) ⚠️ (last snapshot) Fastest
Async AOL + No fsync ⚠️ (mostly) ⚠️ (mostly + OS buffers) Very fast
Async AOL + Interval fsync ⚠️ (mostly) ⚠️ (mostly + since last fsync) Very fast
Async AOL + Every fsync ⚠️ (mostly) ⚠️ (mostly) Very fast
Sync AOL + No fsync ✅ ⚠️ (OS buffers) Fast
Sync AOL + Interval fsync ✅ ⚠️ (since last fsync) Fast
Sync AOL + Every fsync ✅ ✅ Slow

Looks like the quickest and easiest way to use persistent storage is through periodic snapshots. Since these must be set to a number at least greater than 30 seconds, we’ll give it a try with 31.

surreal start --user root --pass secret "mem://tmp/data?snapshot=31s"

We can then connect with Surrealist or the surreal sql --user root --pass secret command and do a CREATE person or two.

After that, use cd tmp/data to go into the folder and wait for the file to show up. After 30 seconds you should see a file called snapshot.bin. That’s the snapshot!

!v�4��y��
         /!cgdefault�?H/�y��mainmain/!ndI��� H�
��t����4P���I��� H�
��t��@~����D�&���I��� H�
...snip
*/***person*rh6xmo5z8cqffvp4hbf6��BQ7
                                     ��
/***user*john��T|��
nameJohn%

Once that file is present, you can stop and restart the database, use SELECT * FROM person and see that the person records are still there. Not bad at all!

We suspect that the most popular options will be the following four:

  • Snapshot only: the fastest option
  • Async AOL + no fsync: the fastest option that tracks every change the moment one happens
  • Sync AOL + no fsync: the fastest option if you think you might have a process crash
  • Sync AOL + every fsync: the most durable option, survives both process and systems crashes

But that remains to be seen.

If you’ve given persistent in-memory storage a try already or have used it since the publication of this blog post, please get in touch and let us know!

Your AI Coding Tool Has Amnesia

I watched one of our engineers explain the same authentication pattern to Claude Code for the fourth time last month. Not because he forgot he’d explained it. Because the tool forgot. Every session, from scratch. “We use JWT validation at the gateway layer, not in individual services.” He’d said it three days ago. And the … continue reading

The post Your AI Coding Tool Has Amnesia appeared first on SD Times.

Why Your Video Player Is Bloating Your Bundle (and How to Fix It)

If you’ve ever cracked open your webpack bundle analyzer and seen a giant chunk labeled video.js, you know the feeling. That sinking realization that your video player alone is heavier than the rest of your app combined.

I hit this exact wall two months ago. A client’s Lighthouse score was tanking on mobile, and after some digging, the culprit was obvious: video.js v8 was shipping nearly 600KB of minified JavaScript. For a page that sometimes didn’t even autoplay video until the user scrolled down. Brutal.

The good news? Video.js just dropped a v10 beta that’s 88% smaller than its predecessor. Let’s dig into why the old version was so bloated, how they fixed it, and how you can migrate without blowing up your existing player setup.

The Root Cause: Legacy Architecture Debt

Video.js has been around since 2010. That’s ancient in JavaScript years. Over time, it accumulated a lot of weight:

  • A custom UI component system that predates modern frameworks
  • A full CSS stylesheet bundled inline
  • Flash fallback code (yes, really — remnants hung around for ages)
  • A custom DOM manipulation layer that duplicated what browsers already do well
  • Middleware and plugin systems with layers of abstraction

The core problem wasn’t any single feature. It was that Video.js was essentially a framework-within-a-framework. It had its own class system, its own event system, its own DOM helpers — all things that made sense in 2010 but are dead weight in 2026.

Here’s what a basic v8 setup looks like for reference:

// v8 — the "classic" way
import videojs from 'video.js';
import 'video.js/dist/video-js.css'; // ~30KB of CSS you may not need

const player = videojs('my-video', {
  controls: true,
  autoplay: false,
  preload: 'auto',
  fluid: true
});

// Even this minimal setup pulls in ~580KB minified (before gzip)

That import drags in the entire kitchen sink. Every UI component, every legacy polyfill, every feature you’re not using. There was no tree-shaking because the architecture wasn’t designed for it.

The Fix: Video.js v10’s Ground-Up Rewrite

The v10 beta takes a fundamentally different approach. Instead of shipping a monolith, it’s been rewritten as a lean core with opt-in features. The headline stat is real: the core player dropped from roughly 580KB to around 70KB minified.

Here’s what changed architecturally:

  • Native web APIs instead of custom abstractions — No more custom DOM layer. They use standard HTMLMediaElement APIs directly.
  • Tree-shakeable ESM modules — Features you don’t import don’t ship. Period.
  • CSS decoupled from JS — Styles are separate and optional. Bring your own design system if you want.
  • Modern baseline — No more IE11 polyfills. The target is modern evergreen browsers.

Step 1: Install the Beta

npm install video.js@next
# or if you want to pin the exact beta
npm install [email protected]

Fair warning: this is a beta. Don’t deploy it to production on a site doing 10 million video plays a day. But for new projects or internal tools? Absolutely worth trying.

Step 2: Update Your Player Initialization

The new API is simpler and more explicit:

// v10 — lean and explicit
import { Player } from 'video.js';

const player = new Player(document.querySelector('#my-video'), {
  controls: true,
  autoplay: false,
  preload: 'auto'
});

// Core is ~70KB minified. That's it. No CSS forced on you.

Notice the shift: you’re importing Player as a named export, not a default function. This is what makes tree-shaking work. The bundler can see exactly what you’re using.

Step 3: Add Only What You Need

Need HLS streaming? Import it explicitly:

import { Player } from 'video.js';
import { HlsHandler } from 'video.js/modules/hls';

const player = new Player(document.querySelector('#my-video'), {
  controls: true,
  techOrder: [HlsHandler]
});

// Only pays the cost for what you actually use

This modular approach means your bundle only includes the code paths you actually exercise. If you’re just playing a simple MP4 with basic controls, you get basic MP4 with basic controls — not an HLS stack, not ad integration hooks, not a quality selector you’ll never render.

Step 4: Handle the CSS Situation

In v8, the player looked decent out of the box because it force-bundled a stylesheet. In v10, you have options:

/* Option A: Import their minimal base styles */
@import 'video.js/styles/base.css';

/* Option B: Just target the native elements yourself */
video {
  width: 100%;
  aspect-ratio: 16 / 9;
}

.vjs-controls {
  /* style controls however you want */
}

I actually prefer this approach. In v8, I spent more time overriding their CSS than I’d like to admit. Now the styling layer is genuinely optional.

Checking Your Bundle Impact

After migrating, verify the size difference. Here’s a quick way to measure:

# Check what video.js contributes to your bundle
npx esbuild your-entry.js --bundle --minify --analyze | grep video

# Or if you're on webpack, the classic:
npx webpack-bundle-analyzer dist/stats.json

On the project I migrated, the video-related JavaScript went from 187KB gzipped to about 22KB gzipped. That’s not a typo. My Lighthouse performance score jumped 12 points on mobile just from this change.

What Breaks (Honestly)

Let’s be real about the tradeoffs:

  • Plugins will break. If you rely on v8 plugins from the ecosystem, they won’t work with v10 out of the box. The plugin API has changed significantly.
  • The component system is different. If you built custom UI components using videojs.registerComponent(), you’ll need to rewrite them.
  • Some events have changed. The core events (play, pause, ended) are the same, but higher-level custom events may have shifted.
  • No more videojs() function. It’s a class now. new Player() is the way.

If you’ve got a complex player with custom skins, multiple plugins, and ad integration, this migration is non-trivial. You’ll want to wait for the stable release and for plugin authors to catch up.

Prevention: Stop Shipping What You Don’t Use

This situation with Video.js is a pattern I see everywhere. Libraries start small, accumulate features for a decade, and suddenly you’re shipping half a megabyte of code to play a 30-second clip.

Some rules I follow now:

  • Audit your bundle quarterly. Run npx bundlephobia <package> before adding any new dependency. Set a size budget and stick to it.
  • Prefer libraries with ESM exports. If a library only ships CommonJS, tree-shaking is either impossible or unreliable.
  • Lazy-load heavy media components. Your video player doesn’t need to be in the initial bundle if the video is below the fold.
  • Consider native <video> first. If you just need play/pause on an MP4, you might not need a library at all. The browser’s native player has gotten remarkably good.
// Sometimes this is all you need. Seriously.
const video = document.querySelector('video');
video.addEventListener('ended', () => showNextContent());
// The browser handles controls, fullscreen, PiP — all of it

The Video.js team deserves credit here. Rewriting a library used by millions of sites is gutsy. Most maintainers would keep bolting features onto the existing architecture. Throwing it out and starting fresh, with a clear focus on bundle size, is the right call.

I’ll be watching the v10 beta closely. If you’re starting a new project with video, it’s worth trying now. If you’re maintaining an existing v8 setup, start planning the migration but don’t rush it — wait for the plugin ecosystem to catch up.

Either way, go check your bundle. You might be surprised what’s hiding in there.

Excel

Background

Early January, 2026, we had concluded first stage of employee annual performance appraisal at departmental level. We now awaited feedback from directorate who are in charge of moderation of performance appraisal feedback scores awarded to each employee by the departmental head. The second stage (moderation) considers the overall performance of the organization, the moderation committee ‘opinion’ about the department among other ‘things.’ In late February, 2026, we received a communication rejecting appraisal results of all employees in the department plus departmental head’s recommendations. One comment stood out in the communication; “the department has not presented any dashboard to demonstrate individual employee’s productivity, improvements on performance measures and so on to justify promotions or any rewards’. I then asked myself two questions. If the agreed performance measures are all captured in an online Human Resource Management System application (HRMS) depending on each employee’s Job Description (JD) and the HRMS has computed the employee’ performance scores, which other ‘things’ are being reviewed at moderation stage? Why is the data captured in the application not being used to create dashboards to help answer questions raised at moderation stage? Based on my years of experience, I had always felt that the performance measures as captured in HRMS were general and would not be sufficient to create any dashboards. Using excel application, this article will attempt to create a simple dashboard which could have been used to monitor one Key Performance Indicator (KPI) on monthly basis and may have been used to measure performance. The critical indicator that has direct impact on customer service is Turn Around Time (TAT).

This article is written in fulfilment of an assignment and as such, it is not to be relied on entirely for academic writing.

Introduction

Microsoft Excel (Excel) was introduced in the 1980s and the initial release included features which in today’s standard are considered basic. The features included cell formatting, simple formulas, and charting capabilities. The Excel ease of use and capabilities made it a favourite and popular appli
 cation among its users. This background became the foundation for what would become the world’s dorminant spreadsheet application used in almost every industry.

Fast forward, the latest iteration of Excel includes capabilities such as puling live information such as scientific figures, market trends and historical statistics from trusted online sources into a worksheet. It has also introduced a wide range of formulas to help in data manipulations as well as AI-based data cleaning feature to address inconsistent text, number formats, and extra spaces thereby reducing time spent on manual data cleaning. These enhancements position Excel as more than just as a spreadsheet tool since the intelligent data platform capabilities enable automation of analysis, connecting to live external data sources and so on hence transforming how professionals work with data.

Excel Formulas & Features

Excel has well over 500 formulas which are broadly categorized as financial, statistical, logical, text, lookup, engineering, and date/time. Due to ever increasing number of new features being released and added to the software, it has become difficult to keep track of all the updates.

Below monitoring feature was developed using hypothetical statistical data for three months period. A simple ‘nested IF’ formular was used to establish number of credit files reviewed within set TAT and those with delayed TAT. A simple pivot table was extracted from the data to summarized the findings and simple line graph plotted to visualize trend of TAT compliance. Another excel feature; ‘slicer’ was added to make it easier for reviewer to interact with the line graph.

Conclusion

Excel has becoming a tool that can be used to perform more functions and as such, any task which generates, collects or processes data should aim at creating a visual summary inform of a dashboard that communicates or monitors if everything is on track to help achieve a bigger goal.

CAP Advanced Patterns — Drafts, i18n, Audit Logging & Production Readiness

Lesson 3.4 — CAP Advanced Patterns & Production Readiness

Table of Contents

  • 1. Draft Handling
  • 2. Localization (i18n)
  • 3. Temporal Data & Audit Logging
  • 4. Deployment Topologies
  • 5. Performance Optimization
  • Top 5 Pitfalls
  • What to Learn Next

Summary: Production CAP Java applications require mastering draft handling for Fiori UIs, localization/i18n, temporal data, deployment topologies (MTA vs Helm), and performance optimization. This lesson covers these advanced patterns with practical examples and configuration.

1. Draft Handling

What is Draft?

Draft enables Fiori’s “edit session” pattern: a user opens an entity for editing, makes changes over multiple requests, then saves or discards. Changes are stored in a draft table until explicitly activated.

User clicks "Edit"     User modifies fields     User clicks "Save"
       │                      │                        │
       ▼                      ▼                        ▼
┌──────────────┐    ┌──────────────┐    ┌──────────────────────┐
│ DRAFT_NEW    │    │ Draft table  │    │ Copy draft → active  │
│ creates draft│───→│ updated      │───→│ Delete draft          │
│ copy         │    │ (multiple    │    │ Return active entity  │
│              │    │  requests)   │    │                       │
└──────────────┘    └──────────────┘    └──────────────────────┘

CDS Annotation

service AdminService {
    @odata.draft.enabled
    entity Books as projection on db.Books;
}

This auto-generates:

  • DRAFT_Books table for storing in-progress edits
  • Draft-specific OData actions: draftEdit, draftActivate, draftPrepare

Draft Handler in Java

@Component
@ServiceName(AdminService_.CDS_NAME)
public class AdminServiceHandler implements EventHandler {

    // Runs when user clicks "Save" (draft → active)
    @Before(event = DraftService.EVENT_DRAFT_SAVE, entity = Books_.CDS_NAME)
    public void validateOnSave(DraftSaveEventContext context) {
        CqnSelect draftQuery = Select.from(Books_.class)
            .where(b -> b.IsActiveEntity().eq(false)  // draft version
                .and(b.ID().eq(context.getCqn().entries().get(0).get("ID"))));

        Books draft = db.run(draftQuery).single(Books.class);

        if (draft.getTitle() == null || draft.getTitle().isBlank()) {
            throw new ServiceException(ErrorStatuses.BAD_REQUEST,
                "Title is required before saving");
        }
        if (draft.getPrice() == null || draft.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException(ErrorStatuses.BAD_REQUEST,
                "Price must be positive");
        }
    }

    // Runs during draft preparation (field validation while editing)
    @Before(event = DraftService.EVENT_DRAFT_PREPARE, entity = Books_.CDS_NAME)
    public void prepareWarnings(DraftPrepareEventContext context) {
        // Side-effect free validation — add warnings but don't reject
        Messages messages = context.getMessages();
        // Add warnings that show in Fiori UI but don't block save
    }
}

Draft Timeout

cds:
  drafts:
    cancellation-timeout: 15d  # Auto-delete drafts after 15 days

2. Localization (i18n)

CDS Text Bundles

// db/schema.cds
entity Books {
    key ID : UUID;
    title  : localized String(200);  // enables translations
    descr  : localized String(2000);
}

The localized keyword generates a Books.texts table:

-- Auto-generated
CREATE TABLE my_bookshop_Books_texts (
    locale VARCHAR(14),
    ID UUID,
    title VARCHAR(200),
    descr VARCHAR(2000),
    PRIMARY KEY (locale, ID)
);

i18n Properties Files

# srv/src/main/resources/i18n/messages.properties (default/English)
book_not_found=Book with ID {0} not found
order_submitted=Order {0} submitted successfully
insufficient_stock=Not enough stock for book {0}. Available: {1}

# srv/src/main/resources/i18n/messages_de.properties (German)
book_not_found=Buch mit ID {0} nicht gefunden
order_submitted=Bestellung {0} erfolgreich eingereicht
insufficient_stock=Nicht genug Bestand für Buch {0}. Verfügbar: {1}

Using Localized Messages in Handlers

@On(event = "submitOrder")
public void onSubmitOrder(SubmitOrderContext context) {
    Books book = db.run(Select.from(Books_.class).byId(context.getBook()))
        .first(Books.class)
        .orElseThrow(() -> new ServiceException(ErrorStatuses.NOT_FOUND,
            MessageKeys.BOOK_NOT_FOUND, context.getBook()));

    if (book.getStock() < context.getQuantity()) {
        throw new ServiceException(ErrorStatuses.CONFLICT,
            MessageKeys.INSUFFICIENT_STOCK, book.getTitle(), book.getStock());
    }
    // ... process order
}

3. Temporal Data & Audit Logging

Managed Aspects (Auto-filled Fields)

using { managed, cuid } from '@sap/cds/common';

entity Orders : cuid, managed {
    // cuid provides: key ID : UUID;
    // managed provides: createdAt, createdBy, modifiedAt, modifiedBy
    orderNo    : String(20);
    totalAmount: Decimal(15,2);
    status     : String(20);
}

CAP automatically fills these fields:

  • createdAt / modifiedAt — server timestamp
  • createdBy / modifiedBy — from JWT user ID

Audit Logging with @changelog

@changelog: [title, price, stock]
entity Books : managed {
    key ID : UUID;
    title  : String(200);
    price  : Decimal(10,2);
    stock  : Integer;
}

CAP generates change log entries for annotated fields, recording old value, new value, user, and timestamp.

Soft Delete Pattern

entity Orders : managed {
    key ID      : UUID;
    // ... fields ...
    deletedAt   : Timestamp;
    deletedBy   : String(255);
}

// Service shows only non-deleted
service OrderService {
    entity Orders as projection on db.Orders
        where deletedAt is null;
}
@On(event = CqnService.EVENT_DELETE, entity = Orders_.CDS_NAME)
public void softDelete(CdsDeleteEventContext context) {
    // Instead of physical delete, set deleted fields
    String orderId = extractId(context);
    CqnUpdate update = Update.entity(Orders_.class)
        .data("deletedAt", Instant.now())
        .data("deletedBy", context.getUserInfo().getName())
        .byId(orderId);
    db.run(update);
    context.setResult(List.of(Map.of("ID", orderId)));
    context.setCompleted(); // prevent generic delete handler
}

4. Deployment Topologies

MTA (Cloud Foundry)

# mta.yaml
_schema-version: "3.1"
ID: my-cap-app
version: 1.0.0

modules:
  - name: my-cap-srv
    type: java
    path: srv
    parameters:
      memory: 1024M
      buildpack: sap_java_buildpack_jakarta
    properties:
      SPRING_PROFILES_ACTIVE: cloud
      JBP_CONFIG_COMPONENTS: '{jres: ["com.sap.xs.java.buildpack.jre.SAPMachineJRE"]}'
    requires:
      - name: my-xsuaa
      - name: my-hana
      - name: my-destination

  - name: my-cap-db-deployer
    type: hdb
    path: gen/db
    requires:
      - name: my-hana

resources:
  - name: my-xsuaa
    type: org.cloudfoundry.managed-service
    parameters:
      service: xsuaa
      service-plan: application
      path: ./xs-security.json

  - name: my-hana
    type: com.sap.xs.hdi-container
    parameters:
      service: hana
      service-plan: hdi-shared

  - name: my-destination
    type: org.cloudfoundry.managed-service
    parameters:
      service: destination
      service-plan: lite

Helm (Kyma)

my-cap-helm/
├── Chart.yaml
├── values.yaml
├── templates/
│   ├── deployment-srv.yaml
│   ├── deployment-sidecar.yaml    # MTX sidecar (if multi-tenant)
│   ├── service.yaml
│   ├── apirule.yaml
│   ├── service-instance-xsuaa.yaml
│   ├── service-instance-hana.yaml
│   ├── service-binding-xsuaa.yaml
│   ├── service-binding-hana.yaml
│   └── job-db-deploy.yaml          # HDI deployer as K8s Job

CF vs Kyma Deployment Comparison

Aspect Cloud Foundry (MTA) Kyma (Helm)
Packaging MTA archive (.mtar) Docker image + Helm chart
DB deployment hdb module (auto) K8s Job with HDI deployer image
Service binding MTA requires BTP Operator CRDs
Scaling CF instances parameter K8s HPA
Routing CF routes + approuter APIRule + Istio
Sidecar (MTX) Separate CF app Separate K8s Deployment

5. Performance Optimization

Query Optimization

// BAD: fetches all columns, all rows
CqnSelect bad = Select.from(Books_.class);

// GOOD: project only needed columns, filter, limit
CqnSelect good = Select.from(Books_.class)
    .columns(b -> b.ID(), b -> b.title(), b -> b.price())
    .where(b -> b.stock().gt(0))
    .orderBy(b -> b.title().asc())
    .limit(50);

Batch Operations

// BAD: N individual inserts
for (Map<String, Object> book : books) {
    db.run(Insert.into(Books_.class).entry(book));
}

// GOOD: single batch insert
db.run(Insert.into(Books_.class).entries(books));

Connection Pool Tuning

spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000

Caching with Spring Cache

@Cacheable(value = "genres", key = "'all'")
public List<Genres> getAllGenres() {
    return db.run(Select.from(Genres_.class)).listOf(Genres.class);
}

@CacheEvict(value = "genres", allEntries = true)
public void onGenreChanged() {
    // Called when genres are modified
}

Top 5 Pitfalls

  1. Not enabling draft for Fiori edit scenarios. Without @odata.draft.enabled, Fiori Elements uses “direct edit” mode which doesn’t support multi-step editing or concurrent user detection.
  2. Ignoring the localized keyword. Adding translations retroactively requires database migration. Plan for i18n from the start using localized on user-facing String fields.
  3. Deploying without a startup probe. CAP Java apps with CDS compilation at startup can take 30-60s. Without a startup probe, Kubernetes kills the pod before it’s ready.
  4. Not batching database operations. Individual inserts/updates in a loop are orders of magnitude slower than batch operations. Always use .entries(list).
  5. MTA descriptor out of sync with actual services. Forgetting to add a new service to mta.yaml causes deployment failures. Keep the MTA descriptor as the definitive deployment manifest.

What to Learn Next

  • Lesson 3.5: CAP Fiori Elements — UI annotations for automatic UI generation
  • Lesson 1.4: Multi-Tenancy — MTX sidecar deployment patterns
  • Lesson 4.1: CI/CD — automating MTA builds and Helm deployments
  • Lesson 4.2: Observability — monitoring CAP applications in production

BiRefNet vs rembg vs U2Net: Which Background Removal Model Actually Works in Production?

BiRefNet vs rembg vs U2Net: Which Background Removal Model Actually Works in Production?

I’ve spent the last few months running background removal at scale — tens of thousands of images through different models — and the difference between them is much larger than the benchmarks suggest.

Here’s the honest breakdown.

Why This Matters More Than You Think

Background removal sounds like a solved problem. It isn’t.

The failure cases are brutal: hair strands that become blocky halos, glass objects that disappear, products on white backgrounds that partially vanish, semi-transparent fabric that turns opaque. Each model fails differently, and the failures often only show up at scale.

The Three Models

rembg — the classic. Wraps ISNet and U2Net under a unified API. Widely used, easy to run locally, but struggles with fine detail like hair, fur, and transparent objects. Good for simple product shots with clear subject-background contrast.

U2Net — the academic ancestor. Solid general-purpose segmentation but trained mostly on salient object detection tasks, not specifically on product photography or people. Fast, low VRAM.

BiRefNet — state of the art as of 2025. Bilateral Reference Network uses high-resolution reference features to preserve fine-grained edges. Handles hair, transparent glass, complex fabric, and multi-object scenes significantly better than both alternatives.

Benchmark: 500 Real Product Images

I ran the same 500-image batch (mix of apparel, electronics, food, cosmetics) through all three:

Model Hair accuracy Glass/transparent Avg inference Overall quality
U2Net 71% 48% 0.8s Acceptable
rembg/ISNet 81% 59% 1.1s Good
BiRefNet 94% 78% 1.4s Excellent

These aren’t cherry-picked. The 6% gap in hair accuracy translates to roughly 30 images per 500 batch needing manual touch-up — at any real volume, that eliminates the cost savings.

Code Comparison

Running rembg locally:

from rembg import remove
from PIL import Image
import io

input_image = Image.open("product.jpg")
output = remove(input_image)
output.save("output.png")

Works fine locally. The catch: rembg on CPU is 3-8 seconds/image. On GPU, needs CUDA setup, model downloads, dependency management. Fine for a one-off script, painful to scale.

BiRefNet via API (no infrastructure):

import requests

response = requests.post(
    "https://api.pixelapi.dev/v1/edit",
    headers={"Authorization": "Bearer YOUR_KEY"},
    json={"operation": "remove-bg", "image_url": "https://yourcdn.com/product.jpg"}
)
clean_url = response.json()["output_url"]  # Transparent PNG, <2s

Same BiRefNet model, no GPU setup, no dependency hell.

When to Use Each

Use rembg/U2Net if:

  • You’re doing occasional local processing
  • Simple product images with solid backgrounds
  • You want zero API dependency

Use BiRefNet if:

  • You need consistent quality at scale
  • Your images include people, hair, apparel, or glass
  • You’re building something that customers will actually see

The Hidden Cost of “Good Enough”

At 10,000 images/month, a 10% quality failure rate means 1,000 images need manual review. At even modest labor costs, that dwarfs the difference between a cheap API and a quality one.

BiRefNet runs on PixelAPI at 10 credits/image. At the Starter plan, that’s 1,000 images for the monthly base cost. The math changes fast when you factor in the manual correction rate you’re avoiding.

Try It

Free credits at pixelapi.dev — no card needed. Run your hardest test images through it.

PixelAPI runs BiRefNet on dedicated RTX GPUs. No cold starts, results in under 2 seconds.

CAP Java Architecture — CDS, Event Handlers & CQN Query API

Lesson 3.1 — CAP Java Architecture & Core Concepts

Table of Contents

  • 1. CAP Java Architecture Overview
  • 2. CDS Compilation Pipeline
  • 3. Event Handler Model
  • 4. CQN Query API
  • 5. Project Structure
  • Top 5 Pitfalls
  • What to Learn Next

Summary: The SAP Cloud Application Programming Model (CAP) for Java provides a framework built on Spring Boot that adds CDS-based domain modeling, a generic runtime for OData/REST services, an event-driven handler model, and built-in BTP service integration. This lesson covers the CAP Java architecture, CDS compilation pipeline, the event/handler model, CQN query API, and how CAP compares to pure Spring Boot development.

1. CAP Java Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                    CAP Java Application                      │
│                                                              │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  CDS Model Layer (.cds files)                        │    │
│  │  - Domain entities, types, associations              │    │
│  │  - Service definitions (projections, actions)        │    │
│  │  - Annotations (UI, auth, validation)                │    │
│  └──────────────────────┬──────────────────────────────┘    │
│                         │ cds compile → CSN (JSON)           │
│  ┌──────────────────────▼──────────────────────────────┐    │
│  │  CAP Java Runtime                                    │    │
│  │  ┌──────────────┐ ┌──────────────┐ ┌─────────────┐  │    │
│  │  │ OData V4     │ │ REST         │ │ Messaging    │  │    │
│  │  │ Adapter      │ │ Adapter      │ │ Adapter      │  │    │
│  │  └──────┬───────┘ └──────┬───────┘ └──────┬──────┘  │    │
│  │         │                │                │          │    │
│  │  ┌──────▼────────────────▼────────────────▼──────┐   │    │
│  │  │            Generic Event Processor             │   │    │
│  │  │  (dispatches CQN events to handlers)           │   │    │
│  │  └──────────────────────┬─────────────────────────┘   │    │
│  │                         │                              │    │
│  │  ┌──────────────────────▼──────────────────────────┐  │    │
│  │  │         Custom Event Handlers (your code)        │  │    │
│  │  │  @Before / @On / @After                          │  │    │
│  │  └──────────────────────┬──────────────────────────┘  │    │
│  │                         │                              │    │
│  │  ┌──────────────────────▼──────────────────────────┐  │    │
│  │  │         Persistence Layer                        │  │    │
│  │  │  CQN → SQL (HANA / H2 / PostgreSQL)             │  │    │
│  │  └─────────────────────────────────────────────────┘  │    │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  Spring Boot Foundation                                 │  │
│  │  DI, Auto-config, Actuator, Security, Web Server        │  │
│  └────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

CAP = Spring Boot + CDS + Generic Runtime

What CAP Adds What Spring Boot Provides
CDS modeling language Dependency injection
Generic CRUD for OData/REST Web server (Tomcat)
Event-driven handler model Auto-configuration
CQN query abstraction Actuator, profiles
BTP service integration Security framework
Draft support Transaction management
Fiori annotations Testing infrastructure

2. CDS Compilation Pipeline

From .cds to Runtime

.cds files          cds compile          CSN (JSON)          CAP Runtime

schema.cds    ─┐                    ┌─ db/csn.json      ─┐
service.cds   ─┤──→ cds compile ──→├─ srv/csn.json     ─┤──→ Java classes
annotations.cds─┘                    └─ EDMX metadata    ─┘    + generic handlers

Domain Model (schema.cds)

namespace my.bookshop;

entity Books {
    key ID     : UUID;
    title      : String(200)  @mandatory;
    descr      : String(2000);
    author     : Association to Authors;
    genre      : Association to Genres;
    stock      : Integer      @assert.range: [0, 999];
    price      : Decimal(10,2);
    currency   : Currency;
    createdAt  : Timestamp    @cds.on.insert: $now;
    modifiedAt : Timestamp    @cds.on.insert: $now  @cds.on.update: $now;
}

entity Authors {
    key ID    : UUID;
    name      : String(100)  @mandatory;
    books     : Composition of many Books on books.author = $self;
    country   : Country;
}

entity Genres : sap.common.CodeList {
    key ID    : Integer;
    parent    : Association to Genres;
    children  : Composition of many Genres on children.parent = $self;
}

Service Definition (service.cds)

using my.bookshop as db from '../db/schema';

service CatalogService @(path: '/catalog') {

    @readonly
    entity Books as projection on db.Books {
        *, author.name as authorName
    } excluding { createdAt, modifiedAt };

    @requires: 'Admin'
    entity Authors as projection on db.Authors;

    // Unbound action
    action submitOrder(book : Books:ID, quantity : Integer) returns { stock : Integer };

    // Bound function
    function getBooksByAuthor(authorId : Authors:ID) returns array of Books;
}

What Gets Generated

From these CDS files, the CAP build produces:

  1. CSN (Core Schema Notation) — JSON representation of the model
  2. EDMX — OData V4 metadata document
  3. HDI artifacts — SQL tables, views for HANA deployment
  4. Java interfaces — Optional typed accessor interfaces

3. Event Handler Model

Handler Registration

@Component
@ServiceName(CatalogService_.CDS_NAME)
public class CatalogServiceHandler implements EventHandler {

    @Autowired
    private PersistenceService db;

    // BEFORE: validation, enrichment (runs before generic handler)
    @Before(event = CqnService.EVENT_CREATE, entity = Books_.CDS_NAME)
    public void validateBook(CdsCreateEventContext context) {
        context.getCqn().entries().forEach(book -> {
            String title = (String) book.get("title");
            if (title == null || title.isBlank()) {
                throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Title is required");
            }
        });
    }

    // ON: replace generic handler (full control)
    @On(event = CqnService.EVENT_READ, entity = Books_.CDS_NAME)
    public void readBooks(CdsReadEventContext context) {
        // Custom read logic (e.g., external API call)
        CqnSelect query = context.getCqn();
        Result result = db.run(query);
        context.setResult(result);
    }

    // AFTER: post-processing (modify result before returning)
    @After(event = CqnService.EVENT_READ, entity = Books_.CDS_NAME)
    public void afterReadBooks(CdsReadEventContext context) {
        context.getResult().forEach(book -> {
            Integer stock = (Integer) book.get("stock");
            book.put("availability", stock > 0 ? "In Stock" : "Out of Stock");
        });
    }

    // Custom action handler
    @On(event = "submitOrder")
    public void onSubmitOrder(SubmitOrderContext context) {
        String bookId = context.getBook();
        Integer quantity = context.getQuantity();

        CqnSelect select = Select.from(Books_.class).byId(bookId);
        Books book = db.run(select).single(Books.class);

        if (book.getStock() < quantity) {
            throw new ServiceException(ErrorStatuses.CONFLICT, "Not enough stock");
        }

        book.setStock(book.getStock() - quantity);
        CqnUpdate update = Update.entity(Books_.class).data(book);
        db.run(update);

        context.setResult(Map.of("stock", book.getStock()));
        context.setCompleted();
    }
}

Event Processing Order

HTTP Request (OData/REST)
    │
    ▼
┌─────────────┐
│  @Before     │ ← Validation, enrichment, authorization checks
│  handlers    │   (multiple handlers possible, ordered by @HandlerOrder)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  @On         │ ← Core logic (generic CRUD or your custom handler)
│  handler     │   Only ONE @On handler executes (first registered wins)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  @After      │ ← Post-processing (modify result, trigger side effects)
│  handlers    │   (multiple handlers possible)
└──────┬──────┘
       │
       ▼
HTTP Response

4. CQN Query API

CQN (CDS Query Notation) is CAP’s type-safe query builder, analogous to JPA Criteria API or jOOQ:

Select Queries

// Simple select
CqnSelect query = Select.from(Books_.class);

// With filter
CqnSelect query = Select.from(Books_.class)
    .where(b -> b.stock().gt(0)
        .and(b.price().lt(50)));

// With expand (join)
CqnSelect query = Select.from(Books_.class)
    .columns(b -> b._all(), b -> b.author().expand())
    .where(b -> b.genre().name().eq("Fiction"))
    .orderBy(b -> b.title().asc())
    .limit(10, 0);

// By key
CqnSelect byId = Select.from(Books_.class)
    .byId("550e8400-e29b-41d4-a716-446655440000");

Insert, Update, Delete

// Insert
Map<String, Object> bookData = Map.of(
    "ID", UUID.randomUUID().toString(),
    "title", "Clean Code",
    "price", 29.99,
    "stock", 100
);
CqnInsert insert = Insert.into(Books_.class).entry(bookData);
Result result = db.run(insert);

// Update
CqnUpdate update = Update.entity(Books_.class)
    .data("stock", 50)
    .byId(bookId);
db.run(update);

// Upsert
CqnUpsert upsert = Upsert.into(Books_.class).entry(bookData);
db.run(upsert);

// Delete
CqnDelete delete = Delete.from(Books_.class)
    .where(b -> b.stock().eq(0));
db.run(delete);

CQN vs JPA/Spring Data

CQN (CAP) JPA / Spring Data
Select.from(Books_.class) bookRepository.findAll()
.where(b -> b.stock().gt(0)) @Query("SELECT b FROM Book b WHERE b.stock > 0")
.columns(b -> b.title(), b -> b.price()) Projection interfaces
db.run(query).listOf(Books.class) Repository method return
CDS associations (auto-join) @ManyToOne, @OneToMany
No entity manager / session EntityManager, L1/L2 cache

5. Project Structure

my-cap-project/
├── pom.xml                    # Parent POM
├── db/
│   ├── schema.cds             # Domain model
│   └── data/                  # CSV test data
│       └── my.bookshop-Books.csv
├── srv/
│   ├── pom.xml                # Service module POM
│   ├── src/main/
│   │   ├── resources/
│   │   │   ├── application.yaml
│   │   │   └── edmx/          # Generated OData metadata
│   │   └── java/
│   │       └── com/sap/demo/
│   │           ├── Application.java
│   │           └── handlers/
│   │               └── CatalogServiceHandler.java
│   └── cat-service.cds        # Service definition
├── app/                        # Fiori UI (optional)
└── mta.yaml                   # BTP deployment descriptor

Key Maven Dependencies

<dependencies>
    <!-- CAP Java SDK -->
    <dependency>
        <groupId>com.sap.cds</groupId>
        <artifactId>cds-starter-spring-boot</artifactId>
    </dependency>
    <!-- OData V4 protocol adapter -->
    <dependency>
        <groupId>com.sap.cds</groupId>
        <artifactId>cds-adapter-odata-v4</artifactId>
    </dependency>
    <!-- HANA database support -->
    <dependency>
        <groupId>com.sap.cds</groupId>
        <artifactId>cds-feature-hana</artifactId>
    </dependency>
    <!-- XSUAA security -->
    <dependency>
        <groupId>com.sap.cds</groupId>
        <artifactId>cds-feature-xsuaa</artifactId>
    </dependency>
</dependencies>

Top 5 Pitfalls

  1. Bypassing the CQN API to use raw JDBC. CAP’s generic runtime (authorization, draft, localization) only works through CQN. Raw SQL bypasses all of it.
  2. Putting business logic in @After instead of @On. @After runs after the database operation. For validation or transformation before persistence, use @Before.
  3. Not understanding that @On replaces the generic handler. If you register an @On handler for READ, the built-in CRUD handler won’t run — you must handle the full query yourself or delegate to db.run().
  4. Ignoring the CDS model as the single source of truth. Don’t define database tables manually. Let CDS generate HDI artifacts, EDMX metadata, and typed interfaces from one model.
  5. Using Spring Data repositories alongside CAP persistence. CAP has its own persistence layer. Mixing Spring Data JPA with CAP causes confusion about which layer handles transactions, caching, and authorization.

What to Learn Next

  • Lesson 3.2: CAP Security — @requires, @restrict, XSUAA integration
  • Lesson 3.3: CAP Remote Services — connecting to S/4HANA and external APIs
  • Lesson 3.4: CAP Advanced Patterns — draft, localization, deployment topologies
  • Lesson 3.5: CAP Fiori Elements — annotations for automatic UI generation

CAP Security & Authorization — XSUAA, @restrict & Multi-Tenant Auth

Lesson 3.2 — CAP Security, Authorization & Authentication

Table of Contents

  • 1. Authentication Architecture
  • 2. CDS-Based Authorization
  • 3. Programmatic Authorization
  • 4. Multi-Tenant Security
  • 5. Testing with Mock Users
  • Top 5 Pitfalls
  • What to Learn Next

Summary: CAP Java integrates with SAP XSUAA and IAS for authentication and provides a declarative authorization model via CDS annotations (@requires, @restrict). This lesson covers the authentication flow, CDS-based authorization, instance-based authorization, programmatic security, multi-tenant security concerns, and testing with mock users.

1. Authentication Architecture

Authentication Flow

Browser/Client          Approuter           XSUAA              CAP Java
     │                     │                  │                    │
     │  GET /app           │                  │                    │
     │────────────────────→│                  │                    │
     │                     │ redirect to login│                    │
     │←────────────────────│                  │                    │
     │                     │                  │                    │
     │  Login credentials  │                  │                    │
     │─────────────────────┼─────────────────→│                    │
     │                     │                  │ Authenticate       │
     │                     │                  │ Issue JWT           │
     │  JWT token          │                  │                    │
     │←────────────────────┼──────────────────│                    │
     │                     │                  │                    │
     │  GET /odata/v4/catalog/Books           │                    │
     │  Authorization: Bearer <JWT>           │                    │
     │────────────────────────────────────────┼───────────────────→│
     │                     │                  │                    │
     │                     │                  │    Validate JWT    │
     │                     │                  │    Extract roles   │
     │                     │                  │    Check @restrict │
     │                     │                  │                    │
     │  200 OK + data      │                  │                    │
     │←───────────────────────────────────────┼────────────────────│

JWT Claims Used by CAP

{
  "sub": "user123",
  "email": "[email protected]",
  "scope": [
    "my-app!t12345.Viewer",
    "my-app!t12345.Admin"
  ],
  "xs.user.attributes": {
    "Country": ["DE", "US"],
    "CostCenter": ["CC100"]
  },
  "zid": "tenant-zone-id",
  "grant_type": "authorization_code"
}

CAP extracts from JWT:

  • Roles — from scope claim (mapped via role templates)
  • User attributes — from xs.user.attributes (for instance-based auth)
  • Tenant — from zid claim (for multi-tenancy)
  • User ID — from sub or user_name claim

Configuration

# application.yaml
cds:
  security:
    authentication:
      mode: jwt          # jwt (production) or dummy (development)
    mock:
      users:
        alice:
          password: alice
          roles: [Admin, Viewer]
          attributes:
            Country: [DE]
        bob:
          password: bob
          roles: [Viewer]
          attributes:
            Country: [US]

2. CDS-Based Authorization

@requires — Role-Based Access to Entire Services/Entities

// Require authentication for the entire service
service CatalogService @(requires: 'authenticated-user') {

    // Public read, but require Viewer role
    @requires: 'Viewer'
    entity Books as projection on db.Books;

    // Only Admin can manage authors
    @requires: 'Admin'
    entity Authors as projection on db.Authors;
}

// Admin-only service
service AdminService @(requires: 'Admin') {
    entity Config as projection on db.AppConfig;
}

@restrict — Fine-Grained Operation-Level Authorization

service CatalogService {

    // Different roles for different operations
    @restrict: [
        { grant: 'READ',   to: 'Viewer' },
        { grant: 'WRITE',  to: 'Editor' },
        { grant: '*',      to: 'Admin'  }
    ]
    entity Books as projection on db.Books;

    // Instance-based: users can only see their own orders
    @restrict: [
        { grant: 'READ', to: 'Customer', where: 'createdBy = $user' },
        { grant: '*',    to: 'Admin' }
    ]
    entity Orders as projection on db.Orders;

    // Attribute-based: users see data from their country only
    @restrict: [
        { grant: 'READ', to: 'Viewer', where: 'country = $user.Country' },
        { grant: '*',    to: 'Admin' }
    ]
    entity Suppliers as projection on db.Suppliers;
}

Pseudo-Variables in @restrict

Variable Meaning Source
$user Current user ID JWT sub claim
$user.<attr> User attribute value JWT xs.user.attributes
$now Current timestamp Server clock
$unrestricted Bypass all restrictions Used in privileged service calls

xs-security.json Mapping

{
  "xsappname": "my-app",
  "tenant-mode": "dedicated",
  "scopes": [
    { "name": "$XSAPPNAME.Viewer", "description": "Read access" },
    { "name": "$XSAPPNAME.Editor", "description": "Write access" },
    { "name": "$XSAPPNAME.Admin",  "description": "Full access" }
  ],
  "attributes": [
    { "name": "Country", "description": "Country filter", "valueType": "string" },
    { "name": "CostCenter", "description": "Cost center", "valueType": "string" }
  ],
  "role-templates": [
    {
      "name": "Viewer",
      "scope-references": ["$XSAPPNAME.Viewer"],
      "attribute-references": ["Country"]
    },
    {
      "name": "Editor",
      "scope-references": ["$XSAPPNAME.Editor", "$XSAPPNAME.Viewer"],
      "attribute-references": ["Country"]
    },
    {
      "name": "Admin",
      "scope-references": ["$XSAPPNAME.Admin", "$XSAPPNAME.Editor", "$XSAPPNAME.Viewer"]
    }
  ]
}

3. Programmatic Authorization

Accessing User Info in Handlers

@Component
@ServiceName(CatalogService_.CDS_NAME)
public class CatalogServiceHandler implements EventHandler {

    @Before(event = CqnService.EVENT_CREATE, entity = Orders_.CDS_NAME)
    public void enrichOrder(CdsCreateEventContext context) {
        UserInfo user = context.getUserInfo();

        // Get user ID
        String userId = user.getName();

        // Check roles
        boolean isAdmin = user.hasRole("Admin");

        // Get user attributes
        List<String> countries = user.getAttributeValues("Country");

        // Set audit fields
        context.getCqn().entries().forEach(order -> {
            order.put("createdBy", userId);
            order.put("createdAt", Instant.now());
        });
    }

    @Before(event = CqnService.EVENT_UPDATE, entity = Orders_.CDS_NAME)
    public void checkOwnership(CdsUpdateEventContext context) {
        UserInfo user = context.getUserInfo();
        if (user.hasRole("Admin")) return; // Admin can update anything

        // Check if user owns the order
        String orderId = context.getCqn().entries().get(0).get("ID").toString();
        CqnSelect check = Select.from(Orders_.class)
            .where(o -> o.ID().eq(orderId).and(o.createdBy().eq(user.getName())));

        if (db.run(check).rowCount() == 0) {
            throw new ServiceException(ErrorStatuses.FORBIDDEN,
                "You can only update your own orders");
        }
    }
}

Privileged (Technical) Access

For service-to-service calls or background jobs that need to bypass authorization:

@Autowired
private CqnService catalogService;

public void backgroundJob() {
    // Run with system/privileged user — bypasses @restrict
    RequestContext privileged = RequestContext.CDS_SYSTEM_USER;

    catalogService.run(privileged, ctx -> {
        CqnSelect allOrders = Select.from(Orders_.class);
        return db.run(allOrders);
    });
}

4. Multi-Tenant Security

Tenant Isolation

In multi-tenant applications, CAP automatically:

  1. Extracts tenant ID from JWT zid claim
  2. Routes database queries to the correct HDI container
  3. Prevents cross-tenant data access
@Before(event = "*")
public void logTenantAccess(EventContext context) {
    String tenant = context.getUserInfo().getTenant();
    log.info("Request from tenant: {}", tenant);
    // CAP automatically routes to correct HDI container
    // No manual tenant filtering needed!
}

Cross-Tenant API Calls

If a provider-side job needs to access tenant data:

public void processTenant(String tenantId) {
    RequestContext tenantContext = RequestContext.of(b -> {
        b.tenant(tenantId);
        b.user(SystemUser.of(tenantId));
    });

    service.run(tenantContext, ctx -> {
        // This runs in the context of the specified tenant
        return db.run(Select.from(Orders_.class));
    });
}

5. Testing with Mock Users

Integration Test Setup

@SpringBootTest
@AutoConfigureMockMvc
class CatalogServiceAuthTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockUser(username = "alice", roles = {"Viewer"})
    void viewerCanReadBooks() throws Exception {
        mockMvc.perform(get("/odata/v4/catalog/Books"))
            .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "bob", roles = {"Viewer"})
    void viewerCannotCreateBooks() throws Exception {
        mockMvc.perform(post("/odata/v4/catalog/Books")
                .contentType("application/json")
                .content("{"title":"New Book","price":29.99}"))
            .andExpect(status().isForbidden());
    }

    @Test
    void unauthenticatedCannotAccess() throws Exception {
        mockMvc.perform(get("/odata/v4/catalog/Books"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(username = "admin", roles = {"Admin"})
    void adminCanCreateBooks() throws Exception {
        mockMvc.perform(post("/odata/v4/catalog/Books")
                .contentType("application/json")
                .content("{"title":"New Book","price":29.99}"))
            .andExpect(status().isCreated());
    }
}

Mock Users in application.yaml (Dev Profile)

spring:
  profiles: default  # local development only
cds:
  security:
    authentication:
      mode: dummy     # accepts any credentials
    mock:
      users:
        admin:
          password: admin
          roles: [Admin, Editor, Viewer]
          attributes:
            Country: [DE, US]
        viewer:
          password: viewer
          roles: [Viewer]
          attributes:
            Country: [DE]

Top 5 Pitfalls

  1. Using authentication.mode: dummy in production. This disables JWT validation entirely. Always use jwt mode in production and ensure XSUAA binding exists.
  2. Confusing scopes and roles. XSUAA scopes are technical (my-app!t12345.Viewer). Role templates map scopes to business roles (Viewer). CDS @requires uses role template names.
  3. Forgetting instance-based authorization for user-specific data. @requires: 'Viewer' grants access to ALL entities of that type. Use @restrict with where clauses for row-level filtering.
  4. Not testing authorization in integration tests. Unit tests often skip security. Use @WithMockUser or CAP’s mock user configuration to test authorization rules.
  5. Hardcoding tenant checks in handlers. CAP handles tenant isolation automatically via the persistence layer. Manual WHERE tenant_id = ? filters are unnecessary and error-prone.

What to Learn Next

  • Lesson 1.4: Multi-Tenancy — XSUAA identity zones and tenant-mode
  • Lesson 3.1: CAP Architecture — handler model and event processing
  • Lesson 3.3: CAP Remote Services — securing service-to-service communication
  • Lesson 1.1: BTP Architecture — XSUAA and trust configuration