Developing UI components that rely on complex, multi-stage backend processing presents a significant challenge to maintaining a fast feedback loop. Our team was tasked with building a series of Svelte components for linguistic data analysis. These components needed to render intricate text annotations—named entities, dependency trees, part-of-speech tags—generated by a Python-based NLP pipeline using spaCy. The initial approach of using static JSON mocks in Storybook quickly proved inadequate. The mocks were brittle, failed to capture the nuances and edge cases of the live NLP model, and created a disconnect between front-end development and the backend reality. A change in the spaCy model or the intermediate business logic would silently break the UI, a failure we would only discover during full integration testing. The development cycle was unacceptably slow.
Our goal became to create a high-fidelity development environment that could be run locally with a single command. This environment had to replicate the entire data pipeline: from the raw text input to the Python spaCy service, through a JVM-based business logic layer, and finally to the Svelte component rendering the result in Storybook. The core of this solution was a deliberate architectural choice: using Kotlin Multiplatform (KMP) as the central nervous system to unify our disparate stacks—Python for ML, JVM for scalable backend services, and JavaScript for the rich front-end. This log details the build-out of this development harness, from local container orchestration to its eventual deployment manifests for an AWS EKS integration environment.
The Technical Pain Point: Brittle Mocks and Integration Blind Spots
Let’s ground the problem in a concrete example. We needed an AnnotatedText
Svelte component. It would receive a block of processed text and highlight named entities (like PERSON
, ORG
, GPE
). A static mock for this in a Storybook story might look like this:
// A typical, but flawed, static mock
export const mockedAnalysis = {
text: "Apple is looking at buying U.K. startup for $1 billion.",
entities: [
{ text: "Apple", label: "ORG", start: 0, end: 5 },
{ text: "U.K.", label: "GPE", start: 27, end: 31 },
{ text: "$1 billion", label: "MONEY", start: 44, end: 54 }
]
};
This works for a simple, happy-path render. But what happens when the spaCy model is updated and now identifies “Apple” as ORG
but also as a FOOD
in a different context? Or what if our business logic layer, which sits between spaCy and the front-end, decides to merge or filter certain entities based on confidence scores? The static mock knows nothing of this. The Svelte developer continues working against a stale contract, leading to a “works on my machine” scenario that explodes upon deployment. We needed to pipe real, dynamically generated data from the entire backend stack directly into Storybook.
The Architectural Blueprint: A KMP-Centric Development Harness
Our solution was a containerized, orchestrated stack managed locally by Docker Compose. This stack would mirror our production setup on AWS EKS.
- spaCy Service (Python/Flask): A simple web service that wraps our spaCy model. It exposes a single endpoint to accept raw text and returns structured linguistic annotations as JSON.
- Orchestrator & Business Logic (Kotlin/Ktor on JVM): A Ktor-based JVM service. This is the
jvmMain
target of our KMP project. It acts as a Backend-for-Frontend (BFF). It receives requests from the UI, calls the Python service, and enforces any business logic (e.g., filtering, enriching data). - Shared Data Models (Kotlin Common): The
commonMain
module in KMP defines the data structures (AnalyzedText
,Entity
, etc.) used by both the JVM backend and the front-end client. This is critical for type safety and eliminating model drift. - Storybook Data Client (Kotlin/JS): The
jsMain
target of our KMP project. This compiles our Kotlin code, including the shared models and an API client, to a JavaScript module. This module is then imported directly into our Storybook stories to fetch live data from the orchestrated backend. - UI Component (Svelte/Storybook): The Svelte components themselves remain unchanged, consuming data via props as they normally would.
Here is a conceptual diagram of the data flow in our local development setup:
graph TD subgraph "Developer's Machine (Docker Compose)" A[Storybook Dev Server] --> B{KMP JS Client}; B --> C[KMP/Ktor BFF]; C --> D[spaCy Python Service]; D --> E[spaCy NLP Model]; end subgraph "Browser" F[Svelte Component in Storybook] end A -- "Hot Module Reload" --> F; B -- "Fetches data for story" --> F; style A fill:#f9f,stroke:#333,stroke-width:2px style F fill:#f9f,stroke:#333,stroke-width:2px
Step 1: Containerizing the spaCy NLP Service
First, we needed to package the Python NLP logic into a reproducible container. We used Flask for the web server due to its simplicity, though FastAPI would be a better choice for production due to its async capabilities and automatic OpenAPI documentation.
The directory structure:
spacy-service/
├── app/
│ ├── main.py
│ └── ... (spaCy model files)
├── Dockerfile
└── requirements.txt
requirements.txt
flask==2.3.3
spacy==3.6.1
# Ensure you download the model you need, e.g.:
# python -m spacy download en_core_web_sm
app/main.py
This is a production-grade starting point. It includes basic logging and error handling. A real-world project would have more robust configuration management.
import os
import logging
import spacy
from flask import Flask, request, jsonify
# --- Configuration ---
# In a real app, use environment variables or a config file
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
MODEL_NAME = os.environ.get("SPACY_MODEL", "en_core_web_sm")
# --- Logging Setup ---
logging.basicConfig(
level=LOG_LEVEL,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# --- Application Factory ---
def create_app():
app = Flask(__name__)
# --- Load spaCy Model ---
# Loading the model is a slow operation, so we do it once on startup.
try:
logger.info(f"Loading spaCy model: {MODEL_NAME}...")
nlp = spacy.load(MODEL_NAME)
logger.info("Model loaded successfully.")
except OSError:
logger.error(f"Could not find spaCy model '{MODEL_NAME}'.")
logger.error("Please run 'python -m spacy download {MODEL_NAME}'")
# Exit if model is essential for the app to function
exit(1)
@app.route("/health", methods=["GET"])
def health_check():
"""Simple health endpoint for readiness probes."""
return jsonify({"status": "ok"}), 200
@app.route("/api/v1/analyze", methods=["POST"])
def analyze_text():
"""
Main endpoint for text analysis.
Expects JSON payload: {"text": "some string"}
"""
if not request.is_json:
return jsonify({"error": "Request must be JSON"}), 400
data = request.get_json()
text = data.get("text")
if not text or not isinstance(text, str):
return jsonify({"error": "Missing or invalid 'text' field"}), 400
if len(text) > 10000: # Basic input validation
return jsonify({"error": "Text exceeds maximum length of 10,000 characters"}), 413
try:
logger.debug(f"Processing text: '{text[:50]}...'")
doc = nlp(text)
result = {
"text": text,
"entities": [
{
"text": ent.text,
"label": ent.label_,
"start_char": ent.start_char,
"end_char": ent.end_char,
}
for ent in doc.ents
],
}
return jsonify(result), 200
except Exception as e:
logger.exception(f"An unexpected error occurred during NLP processing: {e}")
return jsonify({"error": "Internal server error"}), 500
return app
# --- Entry Point ---
if __name__ == "__main__":
app = create_app()
# Use a production-grade WSGI server like Gunicorn or uWSGI instead of app.run()
# For local dev, this is fine.
app.run(host="0.0.0.0", port=5001)
Dockerfile
This Dockerfile
is optimized for smaller image size and better caching by using a multi-stage build.
# Stage 1: Build stage with dependencies
FROM python:3.10-slim as builder
WORKDIR /app
# Set python environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Install build dependencies
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /app/wheels -r requirements.txt
# Download the spaCy model
ARG SPACY_MODEL=en_core_web_sm
RUN python -m spacy download ${SPACY_MODEL}
# Stage 2: Final image
FROM python:3.10-slim
WORKDIR /app
# Copy installed packages from the builder stage
COPY /app/wheels /wheels
COPY /usr/local/lib/python3.10/site-packages/ /usr/local/lib/python3.10/site-packages/
RUN pip install --no-index --find-links=/wheels /wheels/*
# Copy the application code
COPY ./app /app
# Expose port and define command
EXPOSE 5001
CMD ["python", "main.py"]
Step 2: Building the Kotlin Multiplatform Bridge
This is where the architecture comes together. The KMP project provides the shared models and the backend logic that communicates with both the Python service and the front-end.
Project structure (simplified build.gradle.kts
view):
kotlin {
jvm { // For the Ktor BFF
withJava()
testRuns["test"].executionTask.configure { useJUnitPlatform() }
}
js(IR) { // For the Storybook client
browser()
binaries.executable()
}
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
}
}
val jvmMain by getting {
dependencies {
implementation("io.ktor:ktor-server-core-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-netty-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktorVersion")
implementation("io.ktor:ktor-client-cio:$ktorVersion") // JVM HTTP client engine
implementation("ch.qos.logback:logback-classic:$logbackVersion")
}
}
val jsMain by getting {
dependencies {
implementation("io.ktor:ktor-client-js:$ktorVersion") // JS HTTP client engine
}
}
}
}
commonMain/kotlin/com/example/harness/model/NlpData.kt
These data classes are defined once and used everywhere. kotlinx.serialization
provides the @Serializable
annotation for easy JSON conversion.
package com.example.harness.model
import kotlinx.serialization.Serializable
@Serializable
data class NlpEntity(
val text: String,
val label: String,
val start_char: Int,
val end_char: Int
)
@Serializable
data class AnalyzedTextResponse(
val text: String,
val entities: List<NlpEntity>
)
@Serializable
data class AnalysisRequest(
val text: String
)
jvmMain/kotlin/com/example/harness/server/Application.kt
This is the Ktor server. It exposes an endpoint for the front-end, calls the downstream spaCy service, and handles potential errors.
package com.example.harness.server
import com.example.harness.model.AnalysisRequest
import com.example.harness.model.AnalyzedTextResponse
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun Application.module() {
val logger = LoggerFactory.getLogger(Application::class.java)
// In a real project, this URL should come from config (e.g., env vars)
val spacyServiceUrl = System.getenv("SPACY_SERVICE_URL") ?: "http://localhost:5001"
logger.info("Connecting to spaCy service at: $spacyServiceUrl")
val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
// Allow CORS for local development from Storybook's default port
install(CORS) {
allowMethod(HttpMethod.Post)
allowHeader(HttpHeaders.ContentType)
hosts.add("localhost:6006") // Default Storybook port
hosts.add("127.0.0.1:6006")
}
routing {
get("/health") {
call.respond(mapOf("status" to "ok"))
}
post("/api/v1/process") {
try {
val requestBody = call.receive<AnalysisRequest>()
// --- Business Logic Layer ---
// Here you could add caching, validation, or enrichment.
// For now, it's a simple proxy.
logger.info("Forwarding request to spaCy service for text: '${requestBody.text.take(50)}...'")
val response: AnalyzedTextResponse = client.post("$spacyServiceUrl/api/v1/analyze") {
contentType(ContentType.Application.Json)
setBody(requestBody)
}.body()
// A potential business rule: filter out low-value entities
val filteredResponse = response.copy(
entities = response.entities.filterNot { it.label == "CARDINAL" }
)
call.respond(HttpStatusCode.OK, filteredResponse)
} catch (e: Exception) {
logger.error("Error processing request: ${e.message}", e)
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to "Failed to process text"))
}
}
}
}
jsMain/kotlin/com/example/harness/client/NlpClient.kt
This is the magic piece. A type-safe client that compiles to JavaScript and can be used directly from Storybook.
package com.example.harness.client
import com.example.harness.model.AnalysisRequest
import com.example.harness.model.AnalyzedTextResponse
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
// The @JsExport annotation makes this class available to JavaScript.
@JsExport
class NlpClient(private val baseUrl: String = "http://localhost:8080") {
private val client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
})
}
}
/**
* Analyzes text by calling the KMP backend.
* This is an async function that will return a Promise in JavaScript.
*/
suspend fun analyze(text: String): AnalyzedTextResponse {
console.log("KMP/JS Client: Sending request to $baseUrl/api/v1/process")
return client.post("$baseUrl/api/v1/process") {
contentType(ContentType.Application.Json)
setBody(AnalysisRequest(text))
}.body()
}
}
After running the Gradle jsBrowserDistribution
task, this gets compiled into a JS file (.js
) and a type definition file (.d.ts
), giving us full type safety even in our JavaScript-based Storybook stories.
Step 3: Local Orchestration with Docker Compose
Now we tie it all together for local development.
docker-compose.yml
version: '3.8'
services:
spacy-service:
build:
context: ./spacy-service
args:
SPACY_MODEL: en_core_web_sm
image: nlp-harness/spacy-service:latest
ports:
- "5001:5001"
environment:
- LOG_LEVEL=INFO
- SPACY_MODEL=en_core_web_sm
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5001/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
kmp-backend:
# This assumes you have a Dockerfile for your Ktor JVM app
# which is straightforward: FROM openjdk:17-slim, COPY fat.jar, CMD java -jar fat.jar
build:
context: ./kmp-harness-backend
image: nlp-harness/kmp-backend:latest
ports:
- "8080:8080"
environment:
# Service discovery inside Docker Compose uses service names
- SPACY_SERVICE_URL=http://spacy-service:5001
depends_on:
spacy-service:
condition: service_healthy
restart: unless-stopped
networks:
default:
name: nlp-harness-net
With this file, a developer simply runs docker-compose up --build
. This starts the entire backend stack. The front-end Svelte/Storybook environment is run directly on the host machine for the best hot-reloading experience.
Step 4: The High-Fidelity Storybook Integration
Finally, we modify the Storybook story to use our new KMP JS client.
src/components/AnnotatedText.svelte
<script>
export let analysisResult;
// A helper to reconstruct the text with highlighted entities
function getRenderSegments(text, entities) {
if (!entities || entities.length === 0) {
return [{ text, isEntity: false }];
}
// Sort entities by start position to process them in order
const sortedEntities = [...entities].sort((a, b) => a.start_char - b.start_char);
const segments = [];
let lastIndex = 0;
sortedEntities.forEach(entity => {
// Add non-entity text before the current entity
if (entity.start_char > lastIndex) {
segments.push({
text: text.substring(lastIndex, entity.start_char),
isEntity: false
});
}
// Add the entity itself
segments.push({
text: entity.text,
isEntity: true,
label: entity.label
});
lastIndex = entity.end_char;
});
// Add any remaining text after the last entity
if (lastIndex < text.length) {
segments.push({
text: text.substring(lastIndex),
isEntity: false
});
}
return segments;
}
$: segments = getRenderSegments(analysisResult?.text, analysisResult?.entities);
</script>
<div class="annotated-text">
{#if segments}
{#each segments as segment}
{#if segment.isEntity}
<span class="entity" data-label={segment.label}>
{segment.text}
</span>
{:else}
<span>{segment.text}</span>
{/if}
{/each}
{/if}
</div>
<style>
.annotated-text {
line-height: 2;
font-size: 1.1em;
padding: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.entity {
padding: 0.2em 0.4em;
margin: 0 0.2em;
border-radius: 0.3em;
background-color: #bde0fe;
position: relative;
}
.entity::after {
content: attr(data-label);
font-size: 0.7em;
font-weight: bold;
color: #0077b6;
position: absolute;
top: -1.2em;
left: 0;
padding: 0.1em 0.3em;
background-color: #fff;
border: 1px solid #0077b6;
border-radius: 3px;
}
</style>
src/components/AnnotatedText.stories.js
This is the critical change. We replace static mocks with a live data loader.
import AnnotatedText from './AnnotatedText.svelte';
// Import the compiled KMP JS module. The path depends on your build setup.
import { NlpClient } from '../../../kmp-harness-backend/build/js/packages/kmp-harness-backend/kotlin/kmp-harness-backend.js';
export default {
title: 'Components/AnnotatedText',
component: AnnotatedText,
};
const Template = (args) => ({
Component: AnnotatedText,
props: args,
});
// A simple story with static data for basic regression testing
export const StaticExample = Template.bind({});
StaticExample.args = {
analysisResult: {
text: "This is a static example from a mock.",
entities: [{ text: "static example", label: "PHRASE", start_char: 10, end_char: 24 }],
}
};
// The high-fidelity story that loads data from the live backend
export const LiveDataExample = Template.bind({});
LiveDataExample.loaders = [
async () => {
try {
// Instantiate the client that talks to our Ktor backend
const client = new NlpClient('http://localhost:8080');
const analysisResult = await client.analyze(
"Apple Inc. is looking at buying a U.K. startup for over $1 billion from its headquarters in Cupertino."
);
// The return value of the loader becomes the story's args
return { analysisResult };
} catch (error) {
console.error("Failed to load live data for story:", error);
// Provide fallback data on error to prevent Storybook from crashing
return {
analysisResult: {
text: `Error fetching data: ${error.message}. Is the backend running?`,
entities: [],
}
};
}
},
];
Now, when a developer opens the “LiveDataExample” story, Storybook’s loader function executes. It calls our KMP JS client, which sends an HTTP request to the KMP/Ktor backend. The Ktor backend then calls the Python/spaCy service, gets the result, applies its logic, and returns the final data to the client, which populates the Svelte component’s props. The developer sees a render based on the true output of the entire system.
From Local Dev to EKS: Production Manifests
The beauty of this container-based approach is the smooth transition to a production-like environment. For our CI/CD pipeline and staging environments, we deploy the same containers to AWS EKS.
The Kubernetes architecture:
graph TD subgraph "AWS EKS Cluster" Ingress[Ingress] --> KmpService[Service: kmp-backend]; KmpService --> KmpPod[Pod: kmp-backend]; KmpPod --> SpacyService[Service: spacy-service]; SpacyService --> SpacyPod[Pod: spacy-service]; end User[User/Client] --> Ingress;
k8s/deployment.yaml
(A combined example)
apiVersion: apps/v1
kind: Deployment
metadata:
name: spacy-service-deployment
labels:
app: spacy-service
spec:
replicas: 2
selector:
matchLabels:
app: spacy-service
template:
metadata:
labels:
app: spacy-service
spec:
containers:
- name: spacy-service
image: <your-ecr-repo>/nlp-harness/spacy-service:v1.0.0
ports:
- containerPort: 5001
readinessProbe:
httpGet:
path: /health
port: 5001
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1"
---
apiVersion: v1
kind: Service
metadata:
name: spacy-service
spec:
selector:
app: spacy-service
ports:
- protocol: TCP
port: 5001
targetPort: 5001
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kmp-backend-deployment
labels:
app: kmp-backend
spec:
replicas: 3
selector:
matchLabels:
app: kmp-backend
template:
metadata:
labels:
app: kmp-backend
spec:
containers:
- name: kmp-backend
image: <your-ecr-repo>/nlp-harness/kmp-backend:v1.0.0
ports:
- containerPort: 8080
env:
- name: SPACY_SERVICE_URL
# K8s DNS resolves service names
value: "http://spacy-service:5001"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: kmp-backend-service
spec:
type: ClusterIP
selector:
app: kmp-backend
ports:
- protocol: TCP
port: 80
targetPort: 8080
This configuration ensures that communication between services on EKS uses Kubernetes’ internal DNS (http://spacy-service:5001
), while the local setup uses Docker Compose’s DNS. The container images themselves remain identical.
Lingering Issues and Future Optimizations
This solution drastically improved our development velocity and reduced integration bugs, but it’s not without trade-offs. The local Docker Compose stack can be resource-intensive, particularly the spaCy container with a large model loaded into memory. The initial docker-compose up
can be slow as it waits for health checks to pass.
For future iterations, we are exploring two main paths. First, automating the deployment of this entire stack to an ephemeral EKS namespace for each pull request. This would allow us to run automated visual regression tests with a tool like Chromatic against a fully-provisioned, production-like backend. Second, we are investigating GraalVM Native Image for the KMP/Ktor backend. Compiling the JVM application to a native executable could slash its memory footprint and startup time, making the local development experience even leaner and faster. The current approach, however, has already proven its value by bridging the gap between our heterogeneous technology stacks and enabling true high-fidelity component development.