YAGNI in the Age of AI Coding
Architecture Review Boards for Generative AI: A Framework for Governance, Risk Management, and Responsible Adoption
AI agents are having a moment. Coding assistants like Cline and Claude Code, customer-support bots, DevOps copilots — they're moving from demo reels into real workflows fast. It's genuinely exciting, and it's also where a lot of teams are about to learn some expensive lessons. Agents are being deployed across AWS in every shape: DevOps assistants that triage alarms, data-pipeline orchestrators that manage backfills, infrastructure helpers that touch real Lambda, DynamoDB, and S3. Most of them demo beautifully. And most of them break in ways the demo never hinted at. That gap — between "works when you show your manager" and "works at 3 a.m. against traffic nobody anticipated" — is the whole game. This article is about what lives in that gap and how to close it. Why the Gap Exists A demo is a controlled experiment: one friendly user, the happy path, a single run, and someone watching. Production is the opposite of every one of those. It runs thousands of times, against inputs no one imagined, from users who phrase things strangely or adversarially, with no one watching most of the time. The demo shows the best case. Production is defined by its worst case. Almost everything below follows from that one asymmetry. What an AI Agent Actually Is Strip away the hype, and an agent is a loop: Perceive – read inputs and observe system state.Reason – use an LLM (Amazon Bedrock, Anthropic Claude) to decide what to do next.Act – call an API, run a workflow, touch a cloud resource.Observe the result – feed the outcome back in and loop. The difference from traditional automation is that step 2 is dynamic: the agent decides the path at runtime instead of following a flowchart written in advance. That flexibility is the entire value proposition — and the entire source of risk. A flowchart can't surprise you. An agent can. One honest caveat up front: agents "learn from feedback" far less than the marketing suggests. A base model doesn't update itself from production traffic. What actually improves is the scaffolding around it — the prompts, the tool definitions, the retrieved context, the eval suite. Treat "learning" as an engineering practice you own, not a property the model gives you for free. Getting this wrong is itself a demo-to-production trap: teams assume the agent will "figure it out over time," and it won't. Five Things That Break Agents in Production 1. Hallucinations That Cause Real Actions A chatbot that hallucinates gives a wrong answer. An agent that hallucinates invokes a Lambda that shouldn't run, writes garbage to DynamoDB, or deletes an S3 object that mattered. The blast radius is the difference — and it's the reason agent reliability is a different discipline from chatbot quality. What works: Separate reasoning from acting. The LLM proposes; a deterministic layer disposes. Before any state-changing call, validate the proposed action against real system state — does that resource exist, is that ARN in the account you're allowed to touch, does the argument type-check?Ground the model, don't just trust it. Most "hallucinations" in practice are the model guessing at a value it was never given. Retrieve the real value (from the API, a catalog, a config) and pass it in, rather than asking the model to recall it. A model that's handed the correct table name can't hallucinate a wrong one.Tier actions by risk × reversibility. Read-only and easily reversible → auto-run. Destructive or irreversible → human approval, always. This mapping matters more than any confidence score, because it fails safe by construction. A word of caution on "ensemble verification": running three models and voting sounds robust, but correlated models often hallucinate the same wrong thing, and you've now tripled cost and latency for weak protection. Grounding against real system state beats model-vs-model voting almost every time. Lesson: Verify against ground truth, not against another model's opinion. 2. The Infinite Loop Agents get stuck: re-querying the same table, retrying a call that will never succeed, "reasoning" in circles. Each turn burns tokens and, worse, may re-trigger side effects. A stuck agent can quietly retry a failing operation hundreds of times overnight, turning a transient error into a real bill before anyone notices the next morning. What works: Hard budgets, enforced in code (not in the prompt). Max iterations, max wall-clock time, max token spend, max cost per task. When a budget trips, the agent halts and escalates — no exceptions. Asking the model nicely to "not loop" is not a control; it's a suggestion the model is free to ignore.Loop detection on state, not just count. Hash the (action, arguments) pair each turn. If the same hash appears N times, it's looping — break even if it's under the iteration cap. A pure counter misses tight loops that are individually cheap but collectively pointless.Make retries safe. Wrap side-effecting tools in idempotency keys so a retry doesn't double-write. A loop is bad; a loop that duplicates data on every pass is a data-integrity incident. Lesson: Treat the agent like any distributed system — assume it will loop, and build the kill switch outside the model. 3. Context Window Overload Long-running agents accumulate history: prior turns, tool outputs, logs. Two failure modes follow. The obvious one is hitting the context limit and erroring out. The subtler, more dangerous one is degradation before the limit — models reliably lose track of details buried in the middle of a huge context ("lost in the middle") well before they run out of room. The agent doesn't crash; it just quietly gets dumber, which is far harder to catch. What works: Budget context like memory. Keep the system prompt and the current objective pinned. Summarize older turns. Drop tool outputs once they've been acted on.Store, then retrieve — don't accumulate. Write intermediate results to durable state (DynamoDB, a scratchpad file) and pull back only what the current step needs. The context window is working memory, not a database.Compact deliberately. When history crosses a threshold, replace it with a structured summary ("here's what's been done, what's left, what was learned"). This beats a naive sliding window, which silently forgets the decision from step 2 that step 40 depends on. Lesson: More context is not more capability. Curate what the model sees every turn. 4. Tool Execution Is Where Reality Bites The model's reasoning is rarely the flaky part in production — the tools are. Lambda cold starts, DynamoDB throttling, IAM permission gaps, Step Functions timeouts, eventual consistency. The demo hit the happy path every time; production won't. The largest share of real incidents tends to originate here, not in the model. What works: Every tool call is a network call that will fail. Timeouts, exponential backoff with jitter, and a bounded retry count on every one.Distinguish retryable from terminal failures. Throttling → back off and retry. AccessDenied → stop and escalate; retrying a permissions error just wastes time and buries the real problem under noise.Return failures to the model as a structured signal. A clean "S3 PutObject failed: AccessDenied on bucket X" lets the agent adapt or escalate intelligently. A raw stack trace makes it flail and guess.Destructive actions get a human gate, no matter how confident the agent is. Confidence is not the same as authorization. Lesson: A robust tool-execution layer is what actually separates a fragile demo from a production agent. Most reliability engineering lives here. 5. The Observability Black Hole When an agent misbehaves across five AWS services, "it did something weird" is not a bug report anyone can act on. Without tracing, every incident is an archaeology dig — and with a non-deterministic system, it often can't even be reproduced. What works: Trace the full decision, not just the calls. For every turn, log the input context, the model's chosen action and its stated reasoning, the tool result, and the cost. When debugging, "why did it decide that?" is the question that comes up most — and raw API logs can't answer it.One correlation ID per task, propagated through every Lambda, Step Function, and tool call, tied together with X-Ray and structured CloudWatch logs.Alarm on agent-specific signals, not just infra health: escalation rate, loop-detection triggers, cost-per-task, task-completion rate. Infra dashboards can be green while the agent is failing every task. Lesson: You can't debug — or trust — what you can't replay. Log the reasoning, not just the actions. Putting It Together A production agent isn't a smarter prompt. It's a system wrapped around a model: ConcernThe control that makes it safeHallucinated actionsGround against real state; tier actions by risk & reversibilityRunaway loopsHard budgets + state-based loop detection, enforced in codeContext bloatPin the objective, summarize the rest, retrieve on demandFlaky toolsTimeouts, jittered backoff, retryable-vs-terminal handling, idempotencySilent failuresTrace reasoning + actions under one correlation ID Metrics that tell you it's healthy: task-completion rate, human-escalation rate, tool success rate, p99 latency, cost per task, and loop-detection frequency. Watch the trend, not just the absolute value — a slowly climbing escalation rate is usually the first sign something upstream has drifted. The Real Lesson The model is maybe 20% of a production agent. The other 80% is the boring, unglamorous scaffolding — budgets, validation, retries, tracing, human gates — that keeps a probabilistic component from doing damage in a deterministic world. It's the part that never makes the demo, because it isn't impressive to watch. It's only noticeable when it's missing. The demo shows you the 20%. Production is the 80% nobody put in the demo. Build that part, and "intelligent automation" stops being a slide and starts being something you'd actually let near your account.
Why Put a WAF in Front of the Application? Web application firewalls are often introduced as a list of security features: SQL injection protection, cross-site scripting detection, bot mitigation, rate limiting, and access control. Those capabilities matter, but they are only part of the operational problem. The more difficult questions usually appear after deployment: Where should the WAF sit in the traffic path?How do we avoid exposing the origin server?How do we tune blocking rules without interrupting legitimate users?How should security events be investigated?What happens when the application has unusual request formats?How much control do we retain when using a self-hosted solution? This article describes how SafeLine can be used as a self-hosted Web Application Firewall and reverse proxy. It focuses on the deployment model, request-processing flow, practical configuration, and operational lessons that matter when protecting real web applications. The WAF's Position in the Architecture A WAF operates at the HTTP layer. It can inspect requests before they reach the application and evaluate information such as: HTTP methodsURLs and query parametersRequest bodiesHeaders and cookiesSource IP addressesRequest frequency and behavioral patterns This position gives the WAF visibility that a network firewall does not have. A network firewall can allow or deny traffic based on addresses and ports, but it usually cannot determine whether a request contains an SQL injection payload or whether a client is repeatedly probing an administrative endpoint. The WAF also provides a safety boundary around applications that may contain unknown vulnerabilities. It does not replace secure coding, dependency updates, authentication, or database protections. It adds another enforcement point between an untrusted client and the application. The origin application should accept traffic only from the SafeLine host or from a trusted upstream load balancer. If users can bypass the WAF and connect directly to the origin, an attacker can simply avoid the protection layer. Figure 1. SafeLine sits between internet clients and the protected application. The origin should accept traffic only from a trusted SafeLine host. SafeLine's Deployment Model SafeLine is designed to run in a self-hosted environment. It acts as a reverse proxy, receives traffic for configured applications, analyzes the requests, and forwards acceptable requests to the configured upstream service. This deployment model is useful for teams that need: Control over where traffic and logs are processedA WAF that can run on their own infrastructureA relatively simple installation processProtection for applications deployed on virtual machines, bare-metal servers, or private infrastructureA management interface for applications, policies, and security events SafeLine can be installed on a Linux server using Docker-based deployment. Installation can be completed with a single command: Shell bash -c "$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)" -- --en Full guide to install SafeLine: https://ly.cyberserval.tech/b3QLLSJ. After installation, the administrator opens the management console, completes the initial setup, and adds the application that needs protection. Figure 2. Add the application you want to protect in the SafeLine management console. For a production deployment, treat the installation host as a security-sensitive system. At minimum, the host should have: A supported Linux distributionDocker installed and maintainedPersistent storage for configuration and logsRestricted access to the management interfaceA backup strategy for configurationMonitoring for CPU, memory, disk, and network usage The management interface should not be exposed broadly to the public internet. Restricting it by firewall policy, VPN, or an administrative allowlist reduces the attack surface. How a Request Is Processed When a request reaches SafeLine, it passes through several conceptual stages. First, SafeLine identifies the application and routing configuration. It determines which upstream service should receive the request. Next, it inspects the request structure. Depending on the request, this may include the URL, parameters, headers, cookies, and body content. The detection engine then evaluates whether the request resembles an attack. Common categories include: SQL injectionCross-site scriptingCommand injectionPath traversalFile inclusionMalicious scannersAutomated abuseExcessive request rates Finally, SafeLine applies the configured action. The request may be allowed and forwarded, logged for observation, challenged, rate limited, or blocked. Figure 3. HTTP Request Processing Flow in SafeLine A request can be observed before enforcement is enabled, allowing the team to identify legitimate traffic that needs a narrow exception. A team should not begin by blocking every suspicious request in a production environment without understanding the application's normal traffic. A safer rollout sequence is: Put the application behind the WAF.Observe requests and security events.Identify legitimate traffic that may look unusual.Adjust application-specific rules.Enable blocking for high-confidence attack categories.Continue reviewing events after enforcement begins. Deployment Example Assume the application listens on port 8080 and the public domain is app.example.com. The basic workflow is: Install SafeLine on a Linux host.Point the DNS record for app.example.com to the SafeLine host.Add the domain as a protected application.Configure HTTPS and certificates.Configure the upstream as http://application-host:8080.Restrict direct access to the application host.Verify that normal requests reach the application.Send controlled test requests and confirm that events appear in the dashboard. The most important network change is often the origin restriction. The application server should no longer accept arbitrary internet traffic. For example, its firewall can permit the application port only from the SafeLine server's private IP address. This is easy to overlook because the WAF may appear to be working even while the origin remains publicly reachable. Tuning Without Creating an Availability Problem A WAF cannot understand every business rule automatically. Some applications intentionally accept content that resembles an attack payload. Examples include: Security testing platformsDeveloper tools that accept code as inputSearch features that process special charactersAPIs that receive structured expressionsCMS systems with complex administrative requests The correct response to a false positive is usually a narrow exception. Avoid disabling protection for the entire application when only one endpoint or parameter requires special handling. Figure 4. SafeLine’s Custom Allow Rule and Deny Rule Configuration Interface False-positive handling should move from an observed event to the narrowest possible exception. For example, an exception for /api/search and one request parameter is easier to review than a global allowlist for the source IP. Narrow rules also reduce the chance that an attacker can reuse the exception against other endpoints. The application team should be involved in this process. Security events are often difficult to interpret without knowing what the endpoint is supposed to accept. Rate Limiting and Anti-Bot Challenge Application-layer attacks are not limited to SQL injection or cross-site scripting. Automated traffic can also consume resources, abuse login forms, scrape content, and repeatedly call expensive API endpoints. SafeLine provides two complementary controls for this type of traffic: Rate Limiting and Anti-bot Challenge. Rate limiting restricts how frequently a client can access an endpoint. It is useful for protecting login pages, password reset flows, search APIs, file upload endpoints, report-generation APIs, public APIs, and administrative paths. Anti-bot challenges address a different problem. Instead of relying only on request frequency, they challenge clients that exhibit suspicious automated behavior. Legitimate users can usually complete the challenge, while basic scripts, crawlers, and other automated tools may be unable to proceed. These controls are most effective when applied according to the behavior and sensitivity of each endpoint. A global request limit may block legitimate users, especially when many users share the same corporate or mobile network address. A challenge may also be inappropriate for machine-to-machine APIs that cannot interact with a browser-based verification flow. When configuring rate limits and anti-bot protection, consider: Requests per IP addressRequests per account or sessionRequests per endpointBurst behaviorResponse codesLegitimate client retry behaviorWhether the endpoint is accessed by browsers, mobile clients, or APIsGeographic and network distributionWhether the client can complete an interactive challenge A useful operational metric is not simply the number of blocked requests or triggered challenges. It is the relationship between automated traffic and application health. If an increase in blocked requests and challenged sessions coincides with lower application latency, fewer authentication failures, and reduced load on expensive endpoints, the controls may be addressing a real operational problem. The recommended approach is to begin in observation mode, identify the endpoints receiving suspicious automated traffic, and then apply the narrowest suitable control. Rate limiting is usually appropriate for high-frequency access, while anti-bot challenges are more suitable when the goal is to distinguish normal users from automated clients. Investigating Security Events A WAF is most useful when its events can support an investigation. For each event, the operations team should be able to answer: Which application was targeted?Which endpoint received the request?What type of attack was detected?When did the activity begin?Was the request blocked, challenged, or only logged?Did the source continue after the first response?Did the application generate errors?Are other endpoints or applications seeing the same pattern? SafeLine's management interface provides a central place to review protection events and traffic activity. It can help teams distinguish isolated scans from persistent automated activity. Figure 5. SafeLine’s Security Posture Dashboard However, WAF logs should be correlated with application and infrastructure logs. A blocked request may be useful evidence, but it does not prove that the application was compromised. Likewise, an allowed request does not prove that the application is safe. For a complete incident review, correlate WAF events with web server logs, application logs, authentication logs, and host and network telemetry. SafeLine live demo: https://ly.cyberserval.tech/i61INy7. SafeLine Pro Annual License Promotion SafeLine has reached more than 700,000 installations. That number is useful because it reflects broad adoption across different environments, application types, and operational constraints. To mark this milestone, the SafeLine team is offering a customer feedback discount for a Pro annual license. Figure 6. SafeLine 700K Installs Promotion Campaign The promotion details, eligibility requirements, validity period, and redemption process should be confirmed on the campaign page: https://ly.cyberserval.tech/DZAGbHo. Final Takeaways A WAF is not valuable simply because it has a long feature list. It is valuable when it can be placed correctly in the request path, tuned for the application, monitored by the operations team, and maintained as part of the broader security architecture. SafeLine's self-hosted reverse-proxy model makes it practical to introduce application-layer protection without redesigning the application itself. The most important implementation lessons are: Ensure all public traffic passes through the WAF.Restrict direct access to the origin server.Start with observation before enabling aggressive blocking.Handle false positives with narrow, reviewable exceptions.Apply rate limits to specific high-risk endpoints.Correlate WAF events with application and infrastructure logs.Continue using secure development practices alongside the WAF.Evaluate commercial licensing based on operational requirements rather than feature count alone.
Bounded generation: Let AI choose within a space where a deterministic engine guarantees validity. Most attempts to put AI into design tooling adopt a generate-then-fix flow: the model produces an artifact, and downstream steps try to repair it into something valid. For anything that must be manufactured or coordinated, that is backward. This article describes an alternative reference architecture, bounded generation, where the space of possible outputs is constrained up front so that everything the system can produce is valid by construction. The rest of this piece focuses on the solution: its components, the interfaces between them, and the decisions that make the guarantee hold. The Problem With Generate-Then-Fix When a generative model emits geometry directly, correctness is a post-hoc hope. You validate, you repair, you re-prompt, and you still cannot guarantee the next output is buildable, reproducible, or reviewable. The failure surface is unbounded because the output space is unbounded. The safeguards you bolt on afterward — mesh repair, constraint checking, human rework — are all trying to claw back a property the system never had. The real issue is architectural: validity is treated as something you evaluate for at the end rather than something the system cannot violate. The Architectural Principle: Bounded Generation Invert the flow. Define a bounded, valid space first, then let the AI operate only inside it. An expert authors a parametric model whose every parameter combination yields a valid object; the AI selects parameter values; a deterministic engine renders geometry. Correctness moves from a runtime check to a structural property of the system. It is the same instinct behind type systems: rather than validate arbitrary input after the fact, you make invalid states unrepresentable. The parametric model is the grammar, and the parameter bounds are its production rules. Figure 1. Bounded generation: authority (parametric model), contract (parameters), evaluator (headless run plus metrics), and an advisory AI layer that never emits geometry. Illustrative. The Four Components of the Solution The architecture has exactly four parts. Only one is allowed to be non-deterministic, and it is deliberately the part with the least authority. Authority (the model). A deterministic parametric model, in our case an Autodesk Dynamo graph, that is the only source of geometry. Nothing else may produce a vertex, and it is the only component that touches a geometry kernel.Contract (the parameters). The model's exposed inputs, extracted into a small, typed, bounded schema. This is the only surface the AI and orchestration code read and write.Evaluator (the truth). Headless execution for a given parameter vector, followed by a metrics harness that turns the exported geometry into objective numbers.Advisory layer (the AI). The only non-deterministic component. It proposes parameter vectors and interprets metrics, with no path to raw geometry. At worst it picks a suboptimal point inside an already-valid space. The Parameter Schema as a Contract The schema is a load-bearing interface, and three properties make it work. Semantic naming: a parameter is wall_thickness_mm, not slider_7, so the AI and any reviewer know what they are changing. Authored bounds: because the range comes from a human who understands the design, an out-of-range value is not rejected later; it is unrepresentable. Declared couplings: when two parameters must move together to stay manufacturable, the contract records it so an edit to one propagates instead of silently breaking the part. Extracting this is mechanical: a Dynamo .dyn file is JSON, and the exposed sliders already carry their bounds. YAML parameters: - name: wall_thickness_mm # semantic name, not an opaque id type: float min: 1.5 max: 4.0 couples_with: [rib_height_mm] # constraint stitching - name: base_radius_mm type: float min: 20.0 max: 60.0 The Evaluator: Turning Geometry Into Evidence Running the model headlessly is only half of it; the other half measures what came out. A stdlib metrics harness parses each exported mesh (STL or OBJ) and computes objective properties, so a candidate is judged on measurements rather than on how it looks in a render: Export success. Was a mesh actually written? A run can report success and still produce nothing.Watertight and manifold. Welding coincident vertices and counting triangles per edge: a solid is watertight when every edge is shared by exactly two triangles, and any boundary or non-manifold edge is a concrete defect.Geometry metrics. Enclosed volume, surface area, and bounding box, so variants compare quantitatively against targets.Reproducibility fingerprint. A hash of the welded triangles confirms the same vector reproduces the same mesh to tolerance. Because these checks are deterministic, the valid fraction of a whole parameter sweep can be reported as a number rather than asserted. Anatomy of a Request A single design request flows through five stages, and the discipline is in stage three: Resolve intent to a parameter vector within the contract.Execute the engine headlessly with that vector.Reconcile: a zero-exit code only means the file loaded; missing package nodes can silently return null, so check the actual file on disk and the evaluation log before trusting the run.Measure the artifact and return metrics to the advisory layer.The advisory layer iterates or ranks; a human approves. Treating exit code zero as proof of success is exactly how a pipeline ends up quietly shipping empty results. Inside this boundary, the AI maps intent to parameters, runs guided search, learns a surrogate, and ranks candidates. Outside it is a single thing, producing geometry, which stays with the deterministic engine permanently. Keeping the line there lets you adopt more capable models without adding risk. What You Gain and the Trade-Offs Validity by construction. The buildable-space guarantee removes the largest class of failures.Reproducibility and auditability. A design is a vector, verifiable by fingerprint and reviewable as a diff of named parameters, not an opaque mesh.Confidentiality. The advisory layer touches only abstract parameters and metrics, so proprietary internals stay in-house.The cost. Someone must author the model and its bounds, parameters must be named meaningfully, and large spaces need surrogate models rather than exhaustive sweeps. If you need open-ended novel form, this pattern constrains you on purpose. Status and Summary The deterministic substrate, parameter extraction, headless execution, sweeps, and the metrics evaluator are implemented and running on generic models; the AI advisory layer is architecture and roadmap, not benchmarked results. The value of the pattern is that it makes those AI roles safe to add on a foundation that already guarantees validity. Define the valid space first, extract it as a typed contract, measure every result, and confine the AI to choosing inside that space. Constrain the space first, and the AI becomes a trustworthy collaborator inside guardrails instead of an unpredictable geometry generator.
Need a break from reading and want to check out some video content? This episode of The Neuron AI Explained is worth watching! Hosts Corey and Grant take a closer look at GPT-6 Astra and Google's Gemini 3.8 Flash, sharing their thoughts on how these models perform beyond the benchmark scores. Sourced from The Neuron AI Explained Youtube Page If you enjoy the video and want to keep learning, check out The Neuron Academy. You'll find hands-on lessons, structured learning paths, and practical examples to help you build on what you already know about AI. Whether you're looking to improve your prompting skills or get more experience with AI agents and workflows, there's plenty to explore. Well, that's enough from me! Happy watching.
Teams considering or committed to adopting Kotlin often plan a migration as a series of rewrites, swapping Java services for Kotlin counterparts one microservice at a time. In practice, that kind of plan tends to stall before it produces any shipped code. Kotlin's role in the JVM ecosystem is expanding because it doesn't require that kind of commitment. Companies such as Netflix, Adobe, and DoorDash have adopted it incrementally, alongside existing JVM systems, rather than through a full rewrite. The lowest-risk starting point is tests. They give you Kotlin's language benefits, including null-safety, data classes, and expressive lambdas, without touching anything that runs in production. This guide walks through adding Kotlin to an existing Java + Spring Boot project for test sources only. You'll configure Gradle, write JUnit 5 tests in Kotlin, use MockK for Kotlin-native mocking, and apply Kotest assertions to improve test readability. Your production services don't change, and your team starts experiencing Kotlin in a bounded, no-risk context. Why Testing Is the Safest Place to Introduce Kotlin Kotlin test sources are isolated from the production application artifact and don't require Kotlin in the production runtime. They can still affect CI and test infrastructure, but leave production services untouched. That separation from production makes tests the right entry point for a language your team hasn't yet committed to. Introducing Kotlin in test sources means that your existing Java services remain completely unchanged, with no refactoring, no migration risk, and no regressions in production paths. It also allows your team to experiment with real Kotlin syntax on tests they'd be writing anyway, rather than on contrived examples. Developer buy-in builds faster when engineers are writing tests they actually want to write. Kotlin tests tend to be shorter, more readable, and easier to scan at code review. Leadership and platform teams are skeptical of language changes for good reasons. The proposal "we want to use Kotlin for tests" carries almost no risk to evaluate, since it doesn't touch production or your deployment infrastructure. It's a clean, bounded experiment, and the pattern has been validated by engineering organizations across the industry. Adding Kotlin to an Existing Java + Spring Boot Project You don't need to migrate your entire build to use Kotlin in tests. If you're running a standard Spring Boot + Gradle project, you add the Kotlin JVM plugin, pull in kotlin("stdlib") and io.mockk:mockk at test scope, and IntelliJ IDEA recognizes src/test/kotlin as a source set automatically. Open the project in IntelliJ IDEA and update your build.gradle.kts, adding the Kotlin JVM plugin in the plugins block and the test-scoped dependencies: Kotlin plugins { java id("org.springframework.boot") version "4.1.0" // example version, check for latest id("io.spring.dependency-management") version "1.1.5" // example version, check for latest // Add the Kotlin JVM plugin alongside your existing Java plugin kotlin("jvm") version "2.4.10" // example version, check kotlinlang.org for latest stable } dependencies { // Your existing production dependencies remain untouched implementation("org.springframework.boot:spring-boot-starter-web") // Existing test dependency testImplementation("org.springframework.boot:spring-boot-starter-test") // Required for coroutines testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") // Kotlin stdlib (test scope only) testImplementation(kotlin("stdlib")) // MockK: Kotlin-native mocking library (check GitHub for latest stable) testImplementation("io.mockk:mockk:1.14.11") // SpringMockK: @MockkBean and @SpykBean for Spring context tests (check GitHub for latest stable) testImplementation("com.ninja-squad:springmockk:5.0.1") // Kotest assertions, no need for the full test runner (check kotest.io for latest stable) testImplementation("io.kotest:kotest-assertions-core:6.2.3") } tasks.withType<Test> { useJUnitPlatform() } Always check the Kotlin and library documentation for the latest stable releases before pinning versions in a new project. If you’re using Maven, you need to add the kotlin-maven-plugin: XML <properties> <kotlin.version>2.4.10</kotlin.version> </properties> <dependencies> <!-- Existing Spring Boot test dependencies --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!-- Kotlin standard library, test scope --> <dependency> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-stdlib</artifactId> <version>${kotlin.version}</version> <scope>test</scope> </dependency> <!-- MockK --> <dependency> <groupId>io.mockk</groupId> <artifactId>mockk</artifactId> <version>1.14.11</version> <scope>test</scope> </dependency> <!-- Kotest assertions --> <dependency> <groupId>io.kotest</groupId> <artifactId>kotest-assertions-core</artifactId> <version>6.2.3</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.jetbrains.kotlin</groupId> <artifactId>kotlin-maven-plugin</artifactId> <version>${kotlin.version}</version> <executions> <execution> <id>test-compile</id> <phase>test-compile</phase> <goals> <goal>test-compile</goal> </goals> <configuration> <sourceDirs> <source>src/test/kotlin</source> <source>src/test/java</source> </sourceDirs> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> </plugin> </plugins> </build> After saving, IntelliJ IDEA displays a notification to reload the Gradle project. Use Shift+Cmd+I on macOS or Ctrl+Shift+O on Windows and Linux to trigger the sync. Once complete, IntelliJ IDEA automatically recognizes src/test/kotlin as a source set, allowing you to use Kotlin alongside the existing Java test infrastructure, like base classes, testcontainer setup, test data factories, etc. Writing JUnit 6 Tests in Kotlin Writing JUnit 6 tests in Kotlin removes a lot of the friction of test setup. The language's expressiveness lets you focus on the scenario and expected behavior instead. Consider a Spring service that manages orders. Test code often spends more time constructing test data than testing behavior. In Java, creating several variations of the same object can require repeatedly calling setters: Java Order order = new Order(); order.setId(1L); order.setCustomerId(42L); order.setTotal(new BigDecimal("99.99")); order.setStatus(OrderStatus.PENDING); Order cancelledOrder = new Order(); cancelledOrder.setId(1L); cancelledOrder.setCustomerId(42L); cancelledOrder.setTotal(new BigDecimal("99.99")); cancelledOrder.setStatus(OrderStatus.CANCELLED); The setup becomes even more repetitive as a test suite introduces additional scenarios. A test for a large order, for example, would need to repeat the same boilerplate just to change the total. Kotlin's named and default arguments can already reduce that repetition, but you can go further by creating a small test-data DSL that describes orders in terms of the scenarios your tests care about: Kotlin fun orderScenario(block: OrderScenario.() -> Unit): Order = OrderScenario().apply(block).build() class OrderScenario { var id: Long = 1L var customerId: Long = 42L var total: BigDecimal = BigDecimal("99.99") var status: OrderStatus = OrderStatus.PENDING fun pending() { status = OrderStatus.PENDING } fun confirmed() { status = OrderStatus.CONFIRMED } fun cancelled() { status = OrderStatus.CANCELLED } fun forCustomer(id: Long) { customerId = id } fun withTotal(amount: String) { total = BigDecimal(amount) } fun build() = Order(id, customerId, total, status) } Tests can now describe the data they need as scenarios: Kotlin val pendingOrder = orderScenario { forCustomer(42L) withTotal("99.99") pending() } val cancelledOrder = orderScenario { forCustomer(42L) cancelled() } val largeOrder = orderScenario { withTotal("999.99") pending() } The DSL uses Kotlin's lambdas with receivers to make OrderScenario's properties and functions available directly inside the orderScenario { ... } block. Default values provide sensible test data, while individual tests override only the attributes relevant to their scenario. Most importantly, the production Order class can remain completely unchanged in Java. This approach becomes particularly useful as a test suite grows. Instead of accumulating specialized factory methods such as cancelledOrder(), largePendingOrder(), or confirmedOrderForCustomer(), the DSL gives tests a small vocabulary for composing those scenarios themselves. The test describes *what state it needs* without repeating the mechanics of constructing the Java object. A complete JUnit 6 test can then combine the scenario DSL with MockK and Kotest assertions: Kotlin import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Test internal class OrderServiceTest { private val repository = mockk<OrderRepository>() private val orderService = OrderService(repository) @Test fun `returns order when found by id`() { val order = orderScenario { forCustomer(42L) withTotal("99.99") pending() } every { repository.findById(order.id) } returns order val result = orderService.getOrder(order.id) result shouldBe order verify(exactly = 1) { repository.findById(order.id) } } @Test fun `throws when order is not found`() { every { repository.findById(any()) } returns null shouldThrow<NoSuchElementException> { orderService.getOrder(99L) } } } Backtick-delimited test names (`returns order when found by id`) let you write descriptive test names directly in the function declaration, so the name appears naturally in test runner output. This can also save you from using JUnit's `@Named` annotation when you want a readable test name. When a Java repository method returns a nullable `Order?`, the Kotlin compiler forces the test code to account for the null case before using the result. That can surface mismatches between the test and the Java API contract at compile time rather than as unexpected `NullPointerException`s during test execution. A full guide is available in the docs. MockK for Kotlin-Idiomatic Mocking Mockito remains a viable choice when adding Kotlin tests to a Java application, but MockK provides an API designed around Kotlin's syntax. Its concise every { ... } returns ... and verify { ... } constructs make mocked interactions easier to read, while allowing Kotlin tests to mock existing Java classes without changing the production code. This can be particularly useful when testing Java Spring applications that use Reactor. The production application can remain entirely in Java and continue to return Mono or Flux. Kotlin is only introduced in the test suite. MockK handles the mocked dependencies, while Reactor's StepVerifier verifies the resulting publishers. Consider a Java service that returns an order reactively: Java public Mono<Order> findOrder(Long id) { return repository.findById(id); } The service can be tested from Kotlin without changing its implementation: Kotlin import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Test import reactor.core.publisher.Mono import reactor.test.StepVerifier internal class OrderServiceTest { private val repository = mockk<OrderRepository>() private val orderService = OrderService(repository) @Test fun `returns order when found`() { val order = Order( id = 1L, customerId = 42L, total = BigDecimal("99.99"), status = OrderStatus.CONFIRMED ) every { repository.findById(1L) } returns Mono.just(order) val result = orderService.findOrder(1L) StepVerifier.create(result) .expectNext(order) .verifyComplete() verify(exactly = 1) { repository.findById(1L) } } } The production service and repository can remain in Java. The Kotlin test uses MockK's Kotlin-friendly syntax to define the mock behavior, while StepVerifier checks that the returned Mono emits the expected value and completes successfully. The same pattern applies to Flux-returning APIs. For example, a test can verify multiple emitted values without manually subscribing to the publisher: Kotlin every { repository.findAll() } returns Flux.just(order1, order2) StepVerifier.create(orderService.findAll()) .expectNext(order1, order2) .verifyComplete() MockK doesn't replace Reactor's testing tools. Each library handles a different part of the test. MockK provides concise mocking, while StepVerifier provides assertions over Reactor publishers. Kotlin brings those APIs together in a compact test that's focused on the behavior being verified. In practice, that means you can adopt Kotlin for the test source set, use MockK to mock your existing Java dependencies, and test reactive Java code without introducing Kotlin or coroutines into the production application. Kotest Assertions for Cleaner Test Intent You don't need Kotest's full test runner to benefit from its assertion library. The kotest-assertions-core dependency adds infix matchers (shouldBe, shouldNotBe, shouldThrow, shouldContain) that layer on top of your standard JUnit 5 tests. Compare JUnit's assertion style with Kotest's: Kotlin // JUnit 5 assertions Assertions.assertEquals(OrderStatus.CONFIRMED, result.status) Assertions.assertNotNull(result.id) // Kotest matchers (same assertions, different shape) result.status shouldBe OrderStatus.CONFIRMED result.id shouldNotBe null The Kotest versions read in the same order as a sentence describing what you expect. That ordering makes the intent visible without parsing the argument order of assertEquals. shouldThrow is particularly useful for testing Spring service exception paths: Kotlin val exception = shouldThrow<NoSuchElementException> { orderService.getOrder(99L) } exception.message shouldBe "Order 99 not found" The matcher returns the thrown exception, so you can chain further assertions on its properties without additional try/catch blocks. Spring Boot Integration Tests With @MockkBean For Spring slice tests (@WebMvcTest, @DataJpaTest, or a partial @SpringBootTest), you swap Spring's @MockBean for @MockkBean from the springmockk library. It integrates with Spring's test context the same way @MockBean does, but creates MockK instances: Kotlin package com.example.demo.controller import com.example.demo.model.Order import com.example.demo.model.OrderStatus import com.example.demo.service.OrderService import io.mockk.every import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get import com.ninjasquad.springmockk.MockkBean import java.math.BigDecimal import java.util.NoSuchElementException @WebMvcTest(OrderController::class) internal class OrderControllerTest { @Autowired private lateinit var mockMvc: MockMvc @MockkBean private lateinit var orderService: OrderService @Test fun `GET order returns 200 with order data`() { val order = Order( 1L, 42L, BigDecimal("99.99"), OrderStatus.CONFIRMED ) every { orderService.getOrder(1L) } returns order mockMvc.get("/orders/1") .andExpect { status { isOk() } jsonPath("$.id") { value(1) } jsonPath("$.customerId") { value(42) } jsonPath("$.total") { value(99.99) } jsonPath("$.status") { value("CONFIRMED") } } } @Test fun `GET missing order returns 500`() { every { orderService.getOrder(99L) } throws NoSuchElementException("Order 99 not found") mockMvc.get("/orders/99") .andExpect { status { is5xxServerError() } } } } The @MockkBean annotation replaces the service in the Spring context for the duration of the test. IntelliJ IDEA resolves the Kotlin DSL for MockMvc (mockMvc.get(...)) with full autocomplete support, removing the friction of looking up chained method names. Getting Started With Kotlin in Tests Introducing Kotlin through test sources, before any production code changes, is a validated incremental strategy. The tooling is mature, the Gradle setup is a handful of lines, and the first Kotlin test can ship the same day you configure the dependency. Over time, writing Kotlin tests can change how your team thinks about the language. Developers who spend a few months writing Kotlin tests start asking why they can't use data classes in production. They miss null safety in Java service code. Gradual adoption happens organically, driven by genuine preference rather than a top-down mandate. JetBrains and the Spring team have an official strategic partnership focused on improving the developer experience for backend engineers. If you want to explore what Kotlin can do for your team beyond the test suite, kotlinlang.org is the right place to start.
If you've sat through more than one architecture review, you've probably sat through a protracted Kotlin-versus-Java debate that goes nowhere. The team spends weeks comparing Kotlin's concise syntax to Java's maturity, runs a poll on developer preference, and produces a decision matrix nobody fully trusts. Then the meeting ends, and nothing changes, because nobody agreed on what problem they were actually trying to solve. Choosing between infrastructure instruments, such as Kotlin and Java, is an investment decision, and like any investment decision, it needs a thesis. Four business pressures should drive the discussion: time-to-market, operational risk, talent economics, and cross-platform consistency. The Problem With Asking Kotlin or Java When the question of Kotlin or Java arises, syntax and feature comparisons dominate the discussion, because they're concrete and measurable, and a natural place to start. You can count lines of code, benchmark compile times, and run surveys on developer satisfaction, but none of that will tell you if those metrics connect to any business outcome you actually care about. A language can be a good technical fit and still be a poor investment for a particular system. A stable backend handling 50,000 transactions a day with a three-person maintenance team faces a different set of risks than a payments service changing weekly under regulatory pressure, and both look different again from a monorepo running the same business logic across Android, backend, and edge. The JVM language strategy question comes down to where complexity actually lives in the system. Ask where Kotlin meaningfully supports your modernization strategy, and where staying with Java makes better sense right now. Modern Java May Be the Right Answer For a stable, low-change system, modernizing the existing Java stack can address the underlying problem without introducing the costs of another language, toolchain, or learning curve. If the system's main constraints don't align with Kotlin's strengths, staying with Java may offer a better return. If you're considering Kotlin, first identify where its capabilities address a problem worth solving. Start With the Business Problem Drawing on Yuri Geronimus's experience as Senior Staff Engineer and Chief Architect for EMEA Enterprise Payments Platforms at Verifone, the Kotlin Enterprise Playbook frames Kotlin adoption as an enterprise investment decision rather than a language preference. It organizes the business case around four key levers: delivery throughput and time-to-market, defect-cost avoidance, legacy modernization, and talent economics. Time-to-Market If your delivery lead time affects revenue, product launches, or certification cycles, ask whether the team can deliver faster. Kotlin's concise syntax means less boilerplate per feature, and fewer null pointer exceptions reaching production translates to less time spent on hotfixes and incident response. Whether that translates to faster delivery depends on where your bottlenecks sit. If the constraint is product clarity or test coverage rather than boilerplate or type errors, language choice won't fix it. Operational Risk Where are your expensive defects concentrated? Kotlin's type system encodes nullability explicitly, allowing some null-related errors to be caught at compile time rather than in production. For systems where a single production bug triggers a compliance review or a customer-facing outage, that compile-time safety has measurable financial value. For a read-heavy reporting service where reliability is already strong, the business case is thinner. Talent Economics The cost of a JVM talent problem can show up in longer time-to-hire, higher onboarding overhead, attrition, or difficulty moving engineers between teams. Java developers can build on their existing JVM experience when learning Kotlin, making internal mobility a potential part of the business case. The strategic partnership between JetBrains and Spring, announced in May 2025, also means that 27% of Spring developers who've already used Kotlin are a potential internal resource pool you may not have fully mapped. Cross-Platform Consistency If your organization maintains duplicated business logic across an Android app, a backend service, and an edge deployment, that duplication is an operational risk disguised as a technical decision. Kotlin Multiplatform (KMP) can let teams share domain logic, models, and validation across platforms, potentially reducing duplicated implementation and the risk of logic drift. That also reduces the compliance and maintenance burden of finding and fixing the same bug in multiple places. Keep in mind that you should evaluate the case for KMP separately, with the scope of shared code kept deliberately narrow. Kotlin doesn't automatically improve time-to-market any more than a new CI tool does. The question is whether you've identified the constraint it's positioned to address. Measure the Outcome You Actually Care About To make a solid business case for Kotlin, you need a measurable hypothesis. Each business lever maps to an observable metric. Time-to-market connects to delivery lead time and deployment frequency (both DORA metrics your platform team likely already tracks). Operational risk connects to change failure rate, mean time to recovery, and incident cost. Talent economics connects to time-to-hire, time to first commit after onboarding, and attrition rate. Cross-platform consistency connects to duplicated-logic defect rate and the volume of bugs that required fixes in more than one codebase. Those metrics support the business case, but you still have to translate them into financial terms. For an enterprise investment decision, connect the operational improvement to a financial outcome. Faster delivery can reduce the cost of delay, fewer incidents can reduce remediation and customer-impact costs, and shorter onboarding can reduce hiring and training costs. Bear in mind that productivity gains don't translate directly into headcount savings. Faster delivery can instead create capacity for new product work or reduce the cost of delay on a revenue-generating feature. Conflating the two overstates the financial case and creates a credibility problem when someone on the finance team models it out. Also, don't count productivity gains as both lower operating costs and increased revenue in the same proposal. A hypothesis that says "we expect Kotlin's null safety to reduce null-related production incidents by X% over 12 months, based on our current incident log showing Y% of incidents trace to NPEs" is defensible. A hypothesis that says "Kotlin will improve developer productivity" gives you nothing to measure against. Interoperability Makes This a Scoped Decision The Kotlin-or-Java framing misleads because it implies a binary. Kotlin compiles to JVM bytecode and is fully interoperable with Java, so Kotlin and Java files can coexist in the same project without a rewrite. Your existing Spring Boot or Hibernate configurations stay in place. Your Java libraries continue working. A single new service, a shared utilities module, or even the test layer of an existing Java codebase are all valid starting points for introducing Kotlin. IntelliJ IDEA, JetBrains' own IDE, includes a Java-to-Kotlin converter and first-class mixed-project support, so the tooling overhead of running both is lower than teams typically assume. That shifts the question from "should we adopt Kotlin?" to "where should we introduce Kotlin first?" A new, high-change service with a measurable business outcome may be a reasonable candidate. A stable order-management system with a small team and clean Java code can probably wait on Kotlin until a clearer business case emerges. Account for the Iceberg and the J-Curve An honest transition plan accounts for costs that typically don't appear in the optimistic spreadsheet. The J-curve is real. Before a team reaches full Kotlin proficiency, velocity drops. Training takes time, mixed-language code reviews add overhead (reviewers need to understand both languages to catch idiomatic issues), and CI pipelines may need reconfiguration for multi-language builds. Compliance documentation may need updating if your audit trail references language versions or toolchain components. The eventual decommissioning of legacy Java code, if that's the direction, carries its own cost that's easy to leave out of a five-year model. Scope the initial experiment carefully, and build these costs into the transition plan upfront. A proposal that acknowledges the J-curve and plans for it presents a more credible case than one that doesn't. Three Questions for Your Next Architecture Review If your team is heading into an architecture review where Kotlin is on the agenda, these three questions cut through the syntax debate and turn it into a scoped, testable proposal. Where are the expensive defects or operational problems concentrated? Pull the last 12 months of production incidents and categorize the ones with the highest cost (in engineer time, customer impact, or compliance exposure). If a significant share traces to null-related errors or type-safety failures, Kotlin's type system is directly relevant. If the distribution looks different, the investment case needs a different justification. What does your JVM talent pipeline actually look like? Map your current team's Kotlin exposure, your open Java roles, and the average time-to-hire over the past year. If you're competing for senior Java engineers in a tight market and losing candidates to organizations with more modern stacks, that's a talent economics argument worth quantifying. If your team is stable and hiring is closed, the argument doesn't apply. Which service, module, or domain is the lowest-risk place to test whether Kotlin improves the target outcome? Look for something actively changing (so you'll have delivery-cycle data to measure), with a well-defined scope, and without deep compliance dependencies that would complicate a mid-stream language shift. New functionality is often the best candidate. Adding Kotlin tests to an existing Java service can also provide a relatively low-production-risk way to gain experience with the language and toolchain. These questions convert an abstract language debate into a scoped experiment with a falsifiable hypothesis. Run the experiment, measure the result, and expand where it works. Make the Strategic Decision Your CFO and CTO won't ever see a line item for Java versus Kotlin, but they will see the costs of delivery lead time, incidents, onboarding overhead, and duplicated-logic defects. The strategic decision is where to invest to improve those outcomes, domain by domain, with measurement in place. Evaluate Kotlin where you have a number to test against, start with the lowest-risk entry point, and leave stable systems alone when the case is weak. The goal is a better outcome where the investment is justified. If you want to see how other enterprise teams have structured this transition, JetBrains has a practical case for it at kotlinlang.org/lp/enterprise-playbook. How has your team navigated this decision? Share your experience in the comments.
Originally, SQL was SEQUEL — Structured English Query Language. Couchbase supports NL2SQL natively, but the WHERE clause only supports language-defined filters: range, semantic search, and text search. Exploiting Typesafe.ai's Jev models, we now show how you can easily add plain old English filters into the SQL++ WHERE clause. This is a step towards the original vision of making data processing natural and human. DuckDB showed Jev() functions, and Databricks added ai_decide() functions. For Couchbase, this article will show that two function families, jev() and cb_decide(), can be easily added to Couchbase and let a Couchbase query judge documents and return typed answers with the Couchbase Server! This method shows you how to use any model, not just Jev, in Couchbase SQL++. This article explains the sample UDFs and SQL++ published in the cb-jev-functions. In fact, Couchbase users have been doing it since 2018 to interact with external systems. Ask a database for the products under $500, and the answer arrives before you finish the sentence. Ask it for the ones that would make a good gift for a frequent flyer, and you have a project. Someone labels a few thousand examples. Someone trains a classifier, builds an embedding pipeline, or writes a prompt and a parser for the output. Weeks later, it works until the catalog changes. Nothing about the question is hard. A person could answer it by reading each description. The indexes support range predicates, text, or semantic searches. Here is the same question as a Couchbase query with the jev() function: SQL SELECT name, price FROM products WHERE price < 500 AND jev(products, 'a good gift for a frequent flyer'); Results: [ { "name": "Noise-Cancelling Headphones", "price": 299 }, { "name": "Leather Weekender Bag", "price": 450 } ] On the sample catalog in the project described below, you should expect the weekender bag, the noise-canceling headphones and the USB-C cable, and not the cookware set. Two details matter more than the result. The condition is a sentence, not a column or an index. And the function returns a boolean, not a paragraph. That second detail is the whole story. A model that talks cannot sit in a WHERE clause. A model that returns types can. This article explains how that works, how to run it in Couchbase today with two function families, jev() and cb_decide(), and what architects should ask before this gets near production. A Model That Answers in Types On September 15, 2026, TypeSafe AI released Jev, which it calls a System One model. You send it a state (text or JSON) and a set of typed questions. It returns typed answers with calibrated probabilities and never writes a sentence. Each question type lines up with something a database already has: Question typeAnswerSQL analoguenoulProbability that a statement is true, 0 to 1A BOOLEAN predicate in WHEREchoiceOne option from a list you define (up to 255)An enum you can GROUP BYscoreA position on an ordered rubricAn ordinal you can ORDER BY Noul is TypeSafe's name for the yes/no type. Its answer is a number such as 0.93, the probability that the statement is true. It is not a degree. A 0.9 for "a good gift for a frequent flyer" means the model is 90% sure, not that the product is 90% giftable. For degrees, ask a score question. All questions in one request run on the qualified documents in the post-scan filter. In the query above, the functions jev() run on all the documents that qualify for the (price < 500) predicate. TypeSafe quotes 70 to 500 ms end-to-end and $0.042 per million input tokens, with output tokens free. Treat those as vendor figures: the headline 190x speedup claims come from TypeSafe's own evaluations. I've previously written on calling a chat model from Couchbase SQL++. What you got back was prose, which a query cannot sort, threshold, or join on. A number can be sorted, thresholded, and audited, and nothing needs parsing. That is the change. Prior to that, I've also written about Couchbase's ability to call an external REST endpoint. We now exploit the same mechanism. Databricks, DuckDB With Jev. Databricks released ai_decide in Beta on September 30. It takes text and one or more questions and returns a probability, a choice from named criteria, or a score. You call it from SQL for batch work or from a REST API for real-time use, and its launch examples are model routing, tagging reviews, and evaluating agents. Databricks has said the motivation was privacy: many enterprise customers could not send data to Jev, so ai_decide runs on governed data inside the platform. Keep that in mind. It is the first question an architect should ask. The community moved too. pg-jev, a Postgres extension, introduced a jev() function that turns a plain-English condition into a WHERE clause, and several DuckDB ports kept its names: jev, jev_prob, jev_choice, and jev_score. The DuckDB team's write-up covers them. Notice that the two camps chose different shapes. The pg-jev and DuckDB family asks one question per call and returns one value: a predicate, a probability, a label, or a score. ai_decide asks several questions in one call and returns a structured result. Both fit SQL. Couchbase can offer both, because the plumbing underneath is general. Because the infrastructure is open, you can do more with it. Right now! The Plumbing Couchbase Already Had CURL() CURL() has been part of SQL++ since Couchbase 5.0. It calls an HTTP or HTTPS endpoint that returns JSON and gives you the response as data, usable in projections, WHERE clauses, or the FROM clause. In 2018, I used it to run Google's Vision, Translation, and Natural Language APIs against the travel-sample hotel reviews (Using Google Artificial Intelligence Services in Couchbase N1QL). The CURL() documentation sets the rules that matter for model calls: The endpoint must return JSON, the method must be GET or POST, and redirects are not allowed. Responses are capped by the result-cap setting, 20 MB by default.A Full Administrator must put a URL on the CURL() access list before anyone can call it, and matching is by prefix. Other users also need the QUERY_EXTERNAL_ACCESS role. A JavaScript Function Around It JavaScript user-defined functions arrived in Couchbase 7.0. You upload JavaScript to a library through the Query Functions REST API, expose a function from it with CREATE FUNCTION, and call it like a built-in. Inside the function, N1QL() runs a SQL++ statement, including SELECT CURL(...). In 2023, I used exactly this pattern to wrap OpenAI as a function called chatGPT3(). One practical rule: Couchbase JavaScript UDFs cannot rely on global variables, so each function declares its own constants. Here is the core of cb_decide(). It is short because the work is in the question object you pass: JavaScript function cb_decide(state, questions) { var KEY = "your-typesafe-key"; var URL = "https://api.typesafe.ai/v1/systemone"; var opts = { "request": "POST", "header": ["Authorization: Bearer " + KEY, "Content-Type: application/json"], "data": JSON.stringify({ model: "jev-latest", state: state, questions: questions }) }; var q = N1QL("SELECT CURL($1, $2) AS r", [URL, opts]); var resp = null; for (const row of q) { resp = row.r; } q.close(); if (!resp || !resp.answers) { throw new Error("cb_decide: " + JSON.stringify(resp)); } return resp.answers; } Three steps install it. The project's install.sh does the first two and injects your key at install time, so it never lands in source control: Shell # 1. Upload the library to the Query service curl -u Administrator:password -X POST \ http://localhost:8093/evaluator/v1/libraries/decide_lib \ --data-binary @decide_lib.js # 2. Allow the endpoint for CURL() curl -u Administrator:password -X POST \ http://localhost:8091/settings/querySettings/curlWhitelist \ -d '{"all_access": false, "allowed_urls": ["https://api.typesafe.ai"]}' -- 3. Expose the function to SQL++ CREATE OR REPLACE FUNCTION cb_decide(state, questions) LANGUAGE JAVASCRIPT AS "cb_decide" AT "decide_lib"; The access list can also be set in the Query Workbench under Settings. The REST endpoint is named curlWhitelist in the documentation. Two Function Families, One Engine FamilyFunctionsShapeModeled onjevjev, jev_prob, jev_choice, jev_scoreOne question, one value, readable inside WHERE, ORDER BY, and GROUP BYpg-jev and the DuckDB extensionscb_decidecb_decide, cb_decide_prob, cb_decide_choice, cb_decide_scoreMany questions in one request, returned as an object you can storeDatabricks ai_decide The signature of cb_decide() is my own design. It follows the idea of ai_decide, not its exact syntax, and the project is not affiliated with Databricks, TypeSafe AI, or DuckDB. jev(): one question, one value Each function in this family bakes one question type into the wrapper, so a query reads like plain SQL. The sample collections are products and person. SQL -- Filter: a boolean predicate SELECT name, price FROM products WHERE jev(products, 'a good gift for a frequent flyer'); [ { "name": "Leather Weekender Bag", "price": 450 }, { "name": "Cashmere Overcoat", "price": 1800 }, { "name": "Noise-Cancelling Headphones", "price": 299 } ] -- Rank: a probability you can sort on SELECT p.name, jev_prob(p, 'a good gift for a frequent flyer') AS prob FROM products AS p ORDER BY prob DESC; [ { "name": "Noise-Cancelling Headphones", "prob": 0.82 }, { "name": "Leather Weekender Bag", "prob": 0.75 }, { "name": "Cashmere Overcoat", "prob": 0.54 }, { "name": "Swiss Automatic Watch", "prob": 0.48 }, ... Results trucated for brevity here. -- Classify, then group (the subquery computes each label once) SELECT c.category, COUNT(*) AS n FROM ( SELECT jev_choice(p, 'which category fits best?', ['travel gear', 'audio', 'apparel', 'kitchen']) AS category FROM products AS p ) AS c GROUP BY c.category; [ { "category": "apparel", "n": 3 }, { "category": "audio", "n": 1 }, { "category": "kitchen", "n": 2 }, { "category": "travel gear", "n": 2 } ] -- Score: a number on an ordered rubric SELECT p.name, jev_score(p, 'how luxurious is this product?', ['budget', 'mid-range', 'premium', 'luxury']) AS luxury FROM products AS p ORDER BY luxury DESC; [ { "name": "Cashmere Overcoat", "luxury": 2.91 }, { "name": "Swiss Automatic Watch", "luxury": 2.72 }, { "name": "Leather Weekender Bag", "luxury": 2.02 }, { "name": "Noise-Cancelling Headphones", "luxury": 1.77 }, ...Results trucated for brevity The score comes back as a number and can be fractional, so check one real response to confirm the scale before you rely on specific values. cb_decide(): Many Questions, One Trip Sometimes you want several judgments about the same document. Asking them one at a time means one round trip each. cb_decide() sends them together and returns an object with one answer per question name: SQL SELECT s.name, s.d.gift.noul AS gift, s.d.category.choice AS category, s.d.luxury.score AS luxury FROM ( SELECT p.name, cb_decide(p, { "gift": {"type": "noul", "instructions": "Would this make a good gift for a frequent flyer?"}, "category": {"type": "choice", "instructions": "Which category fits best?", "criteria": {"travel gear": "Bags and trip accessories", "audio": "Headphones and speakers", "apparel": "Clothing and accessories", "kitchen": "Cookware and kitchen items"}, "luxury": {"type": "score", "instructions": "How luxurious is this product?", "criteria": ["budget", "mid-range", "premium", "luxury"]} }) AS d FROM products AS p ) AS s ORDER BY gift DESC; [ { "category": "audio", "gift": 0.88, "luxury": 1.75, "name": "Noise-Cancelling Headphones" }, { "category": "travel gear", "gift": 0.79, "luxury": 2.03, "name": "Leather Weekender Bag" }, { "category": "apparel", "gift": 0.63, "luxury": 2.9, "name": "Cashmere Overcoat" }, { "category": "apparel", "gift": 0.62, "luxury": 2.76, "name": "Swiss Automatic Watch" }, { "category": "travel gear", "gift": 0.52, "luxury": 0.01, "name": "USB-C Cable" }, { "name": "Everyday Cotton T-Shirt", "gift": 0.36, "category": "apparel", "luxury": 0.02 }, { "category": "kitchen", "gift": 0.31, "luxury": 0, "name": "Plastic Water Bottle" }, { "category": "kitchen", "gift": 0.17, "luxury": 0.92, "name": "Stainless Steel Cookware Set" } ] A question object has a type, instructions, and, for choice and score, criteria. For a choice, criteria is an object of label to description. For a score, it is an ordered array of 2 to 10 levels. This is also where the Databricks examples translate directly. Model routing, for instance, becomes one call that judges each prompt twice, then a CASE expression picks the tier: SQL SELECT s.prompt_id, s.d.reasoning.noul AS needs_reasoning, s.d.difficulty.score AS difficulty, CASE WHEN s.d.reasoning.noul > 0.5 THEN "large-model" ELSE "small-model" END AS route FROM ( SELECT p.prompt_id, cb_decide(p.prompt, { "reasoning": {"type": "noul", "instructions": "Does answering this require multi-step reasoning?"}, "difficulty": {"type": "score", "instructions": "How difficult is this request?", "criteria": ["trivial", "easy", "moderate", "hard"]} }) AS d FROM prompts AS p ) AS s; The project's samples also include an agent-evaluation query that asks whether each answer follows a policy and how completely it answers the question. The strongest use of cb_decide() is to decide once and keep the result. Write the whole decision object back to the document, then index the part you filter on: SQL UPDATE products AS p SET p.decision = cb_decide(p, { "gift": {"type": "noul", "instructions": "Would this make a good gift for a frequent flyer?"} }) WHERE p.decision IS MISSING LIMIT 50; CREATE INDEX idx_gift ON products(decision.gift.noul); SELECT name FROM products WHERE decision.gift.noul > 0.8; You pay for each document once. Later queries filter on an ordinary field that an index can serve. Run the UPDATE on a small batch first, and rerun it to continue, since the WHERE clause skips documents that already have a decision. Which One Should I Use? You needUseA condition inside WHEREjev(doc, 'condition')A number to sort or thresholdjev_probOne label to group onjev_choice, or cb_decide_choiceAn ordered ratingjev_score, or cb_decide_scoreSeveral judgments about one document in a single requestcb_decideDecisions you will store, index and reusecb_decide with an UPDATE Can I Use a Different Model? Yes, and that is the point of the design. A model is a function from JSON to JSON, and CURL() calls such functions over HTTPS. Changing the model means changing the URL and the request body inside one wrapper. The same pattern is called OpenAI in Select ChatGPT From SQL? You Bet! and Google's Vision, Translation and Natural Language APIs in the 2018 article above. Try It The cb-jev-functions project in couchbaselabs/agentic-apps holds two folders, cb-jev and cb-decide. Each has the library, an install script, sample data, runnable examples, and an offline test that needs no cluster. In either folder: SQL export TYPESAFE_API_KEY="your-typesafe-key" export CB_PASSWORD="your-couchbase-admin-password" ./install.sh # uploads the library, allowlists the endpoint # then run sql/01_register_functions.sql, sql/02_sample_data.sql and sql/03_examples.sql Start with the sample data, not your own. Watch how many calls each query makes. Compare the labels against your own before you trust them. Jev was in early access behind a waitlist when the DuckDB team wrote about it, so check availability first. Where This Is Heading In 2024 I argued in Why Should Databases Go Natural? that databases need natural queries, and that translating English into SQL is a start, not the end state. Capella iQ takes that first step: you describe what you want, and it writes the SQL++ (iQ Interactive). jev() and cb_decide() take another step towards making the query natural and human. The English stays in the query as a typed predicate or a typed question, next to joins, GROUP BY, and window functions. The result is a boolean, an enum, or an ordinal, which SQL++ already knows how to handle. References CURL Function (Couchbase documentation)JavaScript Functions for Query Reference (Couchbase documentation)Query Functions REST API (Couchbase documentation)Using Google Artificial Intelligence Services in Couchbase N1QL (ODBMS.org, 2018)Select ChatGPT From SQL? You Bet! (DZone, 2023)iQ Interactive: Cool Things for Developers on Couchbase Capella iQ (DZone, 2023)Why Should Databases Go Natural? (DZone, 2024)Introducing System One Models and Jev (TypeSafe AI)Introducing ai_decide (Databricks)Jev and DuckDB: Plain-English Conditions in SQL (DuckDB) Images of Couchbase SQL++ With Jev functions
Why Running It Locally The usual reason given is privacy, and it’s a real one. A prompt sent to a local model never leaves the machine, which changes the conversation with your security people from a negotiation into a non-event. But three other reasons matter more day to day. Cost goes to zero at the margin. Iterating on a prompt means running it fifty times. Against a hosted API, it's a line item and a rate limit. Against a local model, it’s free.Tests stop depending on the Internet. A build that reaches a hosted endpoint is a build that fails when someone modifies a key or a region has a bad afternoon.You learn where the abstraction leaks. Spring AI presents one ChatClient over every provider. Running a small local model is the fastest way to find out which parts of that promise hold and which parts quietly assume you are talking to a frontier model. The honest cost is quality and speed. An 8-billion-parameter model on a laptop isn’t a frontier model, and the numbers later in this article are not flattering. That trade is fine for development, prompt iteration, and tests, but it’s a decision to deliberately make for production. The Starting Point The initial set-up is the following: Java 25 (Temurin)Spring Boot 4.1.1Spring AI 2.0.1Local LLM runtime and model (details in the next section) Additionally, a freshly generated Spring Boot project, the state you get from start.spring.io before anything interesting has happened. Web MVC, Actuator, DevTools, their test companions and the Spring AI BOM: XML <properties> <java.version>25</java.version> <spring-ai.version>2.0.1</spring-ai.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc-test</artifactId> <scope>test</scope> </dependency> </dependencies> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> One note on the parent: Spring AI 2.0 is built against Spring Boot 4.x and Spring Framework 7. If you are coming from Spring AI 1.x on Boot 3, this is not a version bump you can do in isolation; package names moved on both sides. Getting a Model onto the Machine In this article, Ollama is the choice: it downloads models, manages their lifecycle, and serves them over HTTP on port 11434. Let’s take it step-by-step. Install Ollama The Windows installer is a one-liner in PowerShell: PowerShell irm https://ollama.com/install.ps1 | iex # >>> Downloading Ollama for Windows... # ######################################## 100.0% # >>> Installing Ollama... # >>> Install complete. Run 'ollama' from the command line. Run Ollama the First Time ollama run pulls the model if it is missing, loads it, and takes you into an interactive prompt. The daemon keeps serving on port 11434 after you leave it: PowerShell ollama run llama3.1 # pulling manifest # pulling 667b0c1932bc: 100% ▕████████████████████████████████████████▏ 4.9 GB # pulling 0ba8f0e314b4: 100% ▕████████████████████████████████████████▏ 12 KB # pulling 455f34728c9b: 100% ▕████████████████████████████████████████▏ 487 B # verifying sha256 digest # writing manifest # success The one-line Windows command below confirms the model is present: PowerShell ollama list # NAME ID SIZE MODIFIED # llama3.1:latest 46e0c10c039e 4.9 GB 12 days ago The detail that matters for Spring AI is that Ollama serves two APIs at once. Its native API lives under /api and an OpenAI-compatible one lives under /v1. Prove the second is alive before you write any Java, as it saves you from debugging a Spring context when the problem is a daemon: PowerShell curl http://localhost:11434/v1/models # {"object":"list","data":[{"id":"llama3.1:latest","object":"model","created":1788341287,"owned_by":"library"}]} Two Ways to Wire It Up Spring AI ships both a dedicated Ollama starter and an OpenAI one that will successfully talk to anything speaking the OpenAI protocol. Both work against the same daemon. Putting both on the classpath and choosing between them with a property is a good way to feel the difference: XML <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-ollama</artifactId> </dependency> Two starters on the classpath would normally mean two competing ChatModel beans. Spring AI resolves that with spring.ai.model.chat property, which names the provider that is currently chosen. The other starter’s auto-configuration backs off. Properties files # Specifies the used starter spring.ai.model.chat=ollama # Native Ollama starter (active when spring.ai.model.chat=ollama) spring.ai.ollama.base-url=http://localhost:11434 spring.ai.ollama.chat.model=llama3.1 spring.ai.ollama.chat.temperature=0.7 # OpenAI-compatible route (active when spring.ai.model.chat=openai) # Ollama does not check the API key, but the starter requires one to be set. spring.ai.openai.api-key=ollama # The /v1 must be part of the base-url: the underlying openai-java client # appends only /chat/completions, so omitting it yields a 404. spring.ai.openai.base-url=http://localhost:11434/v1 spring.ai.openai.chat.model=llama3.1 spring.ai.openai.chat.temperature=0.7 A note is worth making on spring.ai.openai.api-key=ollama. Ollama ignores the value entirely, but the starter refuses to configure itself without one, so it needs a placeholder. Any non-empty string does the job. Another aspect worth noting is the base URL needs its own /v1. The natural thing to write is spring.ai.openai.base-url=http://localhost:11434, on the assumption that the client appends the full /v1/chat/completions path. It does not. Spring AI 2.0 delegates to the official openai-java SDK (4.49.0 here), which appends only /chat/completions. Get this wrong and every call comes back as: Plain Text com.openai.errors.NotFoundException: 404: 404 No hostname, no path, no hint, just the status code twice. The fix is to make /v1 part of the base URL, as above. The same applies to any other OpenAI-compatible server you point this at. Flipping to the OpenAI-compatible route needs no code change at all: PowerShell ./mvnw spring-boot:run -Dspring-boot.run.arguments=--spring.ai.model.chat=openai Both spring.ai.ollama.chat.temperature and spring.ai.ollama.chat.options.temperature are valid, although the latter is deprecated. The starter’s configuration metadata registers both spellings, so your IDE will not flag either. Pick one and be consistent, because a typo in this family of properties fails silently rather than at startup. One Client, Built Once With a provider configured, Spring AI auto-configures a ChatClient.Builder. The temptation is to build a client inside the controller. Resist it; the client carries defaults that belong to the application, not to one endpoint and ChatClient is thread-safe and meant to be built once and shared. So it becomes a bean: Java @Configuration public class ChatConfig { @Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(10) .build(); } @Bean public ChatClient chatClient(ChatClient.Builder builder, @Value("classpath:/prompts/system-prompt.st") Resource systemPrompt, ChatMemory chatMemory) { return builder .defaultSystem(systemPrompt) .defaultAdvisors(new SimpleLoggerAdvisor(Ordered.LOWEST_PRECEDENCE), MessageChatMemoryAdvisor.builder(chatMemory).build()) .build(); } } Three decisions are packed into those twenty lines. Prompts Live in Files defaultSystem() accepts a Resource, so the system prompt is a StringTemplate file under src/main/resources/prompts rather than a string literal in Java. It reads better, diffs better, and can be edited without recompiling: Plain Text You are a generalist. Answer as briefly and precisely as you can both general and specific questions. A system prompt is worth setting even for a toy. Small local models are chatty by default, and a brief system message is the difference between a sentence and five paragraphs of preamble. Memory Is an Advisor Every ChatClient call is stateless unless something makes it otherwise. MessageChatMemoryAdvisor is that something. Before each call, it loads the history for a conversation and appends it to the prompt, and after each call it stores the new user and assistant messages. The store behind it here is MessageWindowChatMemory, an in-memory map keyed by conversation id that keeps the last ten messages and drops the oldest when the window fills. Which conversation a request belongs to is decided per call, in the next chapter. Logging Sits Closest to the Model SimpleLoggerAdvisor logs the request and the response at DEBUG. Its position in the chain is set by the order argument and Ordered.LOWEST_PRECEDENCE puts it last, after the memory advisor has done its work, so what it logs is the prompt the model actually receives, history and format instructions included. It stays quiet until you switch its logger on: Properties files logging.level.org.springframework.ai.chat.client.advisor=DEBUG That one line is the most useful debugging aid in this article. When a model misbehaves, the first question is always “what did it actually see?” and this answers it. The Endpoint The controller is thin. It resolves a conversation ID from an optional header, delegates to a service, and echoes the ID back so the caller can continue the same conversation on the next request: Java @RestController @RequestMapping("/api") class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService = chatService; } @GetMapping("/v{version}/chat") public ResponseEntity<Response> chat(@RequestParam String message, @RequestHeader(value = "X-Conversation-Id", required = false) String conversationId) { String id = conversationId == null ? UUID.randomUUID().toString() : conversationId; var response = chatService.respond(message, id); return ResponseEntity.ok() .header("X-Conversation-Id", id) .body(response); } } The v{version} segment is not a hand-rolled convention. Spring Framework 7 has first-class API versioning and Boot 4 configures it from properties: Properties files spring.mvc.apiversion.use.path-segment=1 spring.mvc.apiversion.supported=1.0 spring.mvc.apiversion.default=1.0 The first line tells MVC that the version is the path segment at index 1, in /api/v1/chat that is v1, and the default parser understands the leading v. The second lists what the application supports, so a request for /api/v2/chat is rejected before any controller runs. The third is the version assumed when a request carries none. The service is where the ChatClient fluent API earns its keep. One chained call covers the prompt template, the conversation, the tools and the output type: Java @Service public class ChatService { private final ChatClient chatClient; private final Resource userPrompt; private final TenantService tenantService; public ChatService(ChatClient chatClient, @Value("classpath:/prompts/user-prompt.st") Resource userPrompt, TenantService tenantService) { this.chatClient = chatClient; this.userPrompt = userPrompt; this.tenantService = tenantService; } public Response respond(String message, String conversationId) { return chatClient.prompt() .user(userSpec -> userSpec.text(userPrompt) .param("message", message)) .advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, conversationId)) .tools(tenantService) .call() .entity(Response.class); } } Line by line, the user message is a template with a {message} placeholder, filled with .param(), which keeps user input out of the template’s structure and the template itself readable: Plain Text Provide a short, to-the-point answer to the below message: {message} The advisors() block hands the conversation id to the previously mentioned memory advisor, under the well-known key ChatMemory.CONVERSATION_ID. Two requests with the same X-Conversation-Id header share history. A request without the header starts a fresh conversation and is told its id in the response. tools() and entity() get their own chapters. Plain Text GET /api/v1/chat?message=In one sentence, what is quantization of an LLM? LLAMA3.1: Quantization of a large language model (LLM) refers to the process of approximating its weights and activations with lower-precision data types (such as integers or fixed-point numbers) to reduce memory usage and computational overhead, while minimizing the impact on model performance. If you are running with DevTools and edit application.properties or a prompt file under src/main/resources, nothing happens until the file is copied into target/classes. DevTools watches the build output, not the source tree. A rebuild in your IDE triggers it; editing the file in a plain text editor doesn’t, and you’ll spend a while wondering why your fixed base URL is still returning 404. Answers as Java Records Text is informative, but typed objects are the reason to put a model behind a Spring service. entity() takes a class, derives a JSON schema from it, appends format instructions to the prompt, and parses what comes back. The endpoint returns this: Java public record Response( @JsonPropertyDescription(""" The category of the request message. \ SPECIFIC when the message refers to a particular tenant, identified by its unique identifier, \ so that answering requires the tenant information tool. \ GENERAL for any other message, such as general knowledge or technical questions \ that can be answered without tenant data.""") ResponseCategory category, @JsonPropertyDescription("The actual response message text") String text) { } public enum ResponseCategory { GENERAL, SPECIFIC } The @JsonPropertyDescription annotations are not decoration. They flow into the generated schema and the schema is what the model reads, so a description is the only place a field’s meaning gets explained. The enum becomes an enum constraint in the schema, which is why the model reliably answers with one of the two constants rather than a synonym. The category description is deliberately a paragraph, not a label. Giving the Model a Tool A model knows nothing about your data specifics (tenants’ information as part of this experiment). Tool calling is how it asks. You describe a method; the model decides whether to call it and with what arguments, Spring AI executes it and feeds the result back, and the model writes its answer from that. The description is the interface; it’s all the model sees: Java @Service public class TenantService { private static final Logger logger = LoggerFactory.getLogger(TenantService.class); private final Map<String, String> tenantInfo = Map.of( "TNG", "Tenant TNG is a well known TEM services provider.", "FED", "Tenant FED is Roger Federer's foundation.", "DJO", "Tenant DJO is Novak Djokovic's foundation." ); @Tool(name = "get-tenant-info-by-id", description = "Retrieve additional information by tenant id") public String infoByTenantId(@ToolParam(description = "The unique identifier of a tenant") String tenantId) { logger.info("Retrieving additional info for tenant with id {}", tenantId); return tenantId == null ? "N/A" : tenantInfo.getOrDefault(tenantId, "N/A"); } } Passing the bean to .tools(tenantService) in the service is all the registration there is. Spring AI scans it for @Tool methods and builds the JSON schema for their parameters from the @ToolParam descriptions. The log line is deliberate. Whether the model decided to call the tool and what it passed, is otherwise invisible. The null guard matters too. A model that decides to call the tool but fails to extract an argument sends nothing for tenantId and a Map.getOrDefault(null, …) on an immutable map throws. Returning "N/A" instead lets the model explain the gap in its own words, which is exactly what happens in the first test case below. Testing Without a Model A local model makes tests possible offline, but it does not make them good tests. The output varies run to run, and every assertion becomes a guess about phrasing. For anything that checks your code rather than the model’s, replace the ChatModel bean with a mock. Spring AI‘s auto-configuration builds the ChatClient.Builder from whatever ChatModel is in the context, so a @MockitoBean is enough to take the daemon out of the picture entirely: Java @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureMockMvc class ChatControllerTests { @MockitoBean private ChatModel chatModel; @Autowired private MockMvcTester mvc; @BeforeEach void init() { // ChatClient copies the model's defaults into every request, so a bare mock does not work when(chatModel.getOptions()) .thenReturn(ChatOptions.builder().build()); } @Test void chat() { var message = "Local models keep your prompts on your own machine."; var modelOutput = """ {"category": "GENERAL", "text": "%s"} """.formatted(message); var response = new ChatResponse(List.of(new Generation(new AssistantMessage(modelOutput)))); when(chatModel.call(any(Prompt.class))) .thenReturn(response); assertThat(mvc.get() .uri("/api/v1/chat") .param("message", "Why run locally?")) .hasStatusOk() .bodyJson() .convertTo(Response.class) .isEqualTo(new Response(ResponseCategory.GENERAL, message)); } } The mock returns the JSON a model would return, and the test asserts that your controller, service, template and record mapping serve it back as a Response. The model’s ability to produce that JSON is a separate question and not one a unit test should be asking. Two things are quietly bypassed here, and it is worth knowing which. Tool execution happens inside the concrete ChatModel implementation, so a mocked model never calls TenantService. The tool is registered but inert. The memory advisor, on the other hand, is real; it runs, stores the exchange in the in-memory window under a freshly generated conversation ID, and does no harm. A note on the chatModel mock is worth making. If only mocking ChatModel and stubbing just call(), the first request fails with: Plain Text NullPointerException: Cannot invoke "ChatOptions.mutate()" because the return value of "ChatModel.getOptions()" is null ChatClient reads the model’s default options and merges them into every request. Mockito returns null for the unstubbed getter and the merge breaks. One line in a @BeforeEach fixes it, but the stack trace points into Spring AI internals rather than at your test, so it costs more time than it should. Testing the Integration With the application started, four requests on the same conversation exercise the whole chain: template, memory, tool, structured output. The X-Conversation-Id header ties them together. What each one did is known from the log line in TenantService and from the advisor log previously switched on. Use Case 1: Ask by “id” PowerShell curl -G http://localhost:8080/api/v1/chat -H "X-Conversation-Id: 123-1bc-981" --data-urlencode "message=Provide info for tenant with id FED" # {"category":"GENERAL","text":"Tenant information not available"} The tool was invoked, but with no tenantId. The model recognized that the question needed the tool and then failed to map "FED" onto the parameter, so the method returned N/A and the model paraphrased it. Use Case 2: Same Question, Phrased With the Parameter’s Own Description PowerShell curl -G http://localhost:8080/api/v1/chat -H "X-Conversation-Id: 123-1bc-981" --data-urlencode "message=Provide info for tenant with unique identifier FED" # {"category":"GENERAL","text":"Tenant FED is Roger Federer's foundation."} Tool invoked, tenantId mapped to FED, correct answer. The only change was “id” becoming “unique identifier,” the exact words in the @ToolParam description. Use Case 3. The Winning Phrasing, Different Tenant PowerShell curl -G http://localhost:8080/api/v1/chat -H "X-Conversation-Id: 123-1bc-981" --data-urlencode "message=Provide info for tenant with unique identifier DJO" # {"category":"GENERAL","text":"Tenant DJO is an abbreviation for DJ Okaya."} The tool was not invoked. The model answered from nothing, fluently and wrongly. Use Case 4: The Identical Request, Sent Again PowerShell curl -G http://localhost:8080/api/v1/chat -H "X-Conversation-Id: 123-1bc-981" --data-urlencode "message=Provide info for tenant with unique identifier DJO" # {"category":"GENERAL","text":"Tenant DJO is Novak Djokovic's foundation."} This time the tool was invoked, tenantId mapped to DJO, and the answer is right. Nothing changed between use cases 3 and 4 except the conversation history being one exchange longer. Four requests, one tool, three different behaviors: called without arguments, called correctly, and not called at all, the last two for the same input. The temperature of 0.7 explains some of the variance, but the deeper issue is that an 8-billion-parameter model is weak at the two judgment calls tool use requires: should I call this, and what goes in the parameter. Two mitigations are visible in the results. Wording the user’s request in the same vocabulary as the @ToolParam description made the difference between use cases 1 and 2, so descriptions should use the words users will use. And the null guard in TenantService turned a would-be exception into a polite non-answer. Neither makes use case 3 go away, that probably needs a lower temperature, a system prompt that instructs the model to always use the tool for tenant questions, or a bigger model. What It Actually Feels Like Measured on a laptop, Intel Core Ultra 7 155H, 32 GB RAM, RTX 500 Ada, running llama3.1 8B through the endpoint above: 14.2s – first request after startup, three-bullet answer11.3s – same request, model already warm Double-digit seconds for a paragraph and a tool call adds a second round trip to the model on top. That’s workable for development and unacceptable for a request a user is waiting on, which is the honest headline: local models are a development and testing tool at this size. The quality gap is sharper than the speed gap, and it’s worth seeing rather than being told about. Asked a question about the very tool serving it: PowerShell GET /api/v1/chat?message=In one sentence: what is Ollama? LLAMA3.1: Ollama is a brand of vegan, plant-based egg substitute made from a combination of pea protein, potato starch and other ingredients. Confident, fluent, well-formed and entirely invented. The model’s training data predates the tool it’s running inside. Put that next to "DJ Okaya" from before and the pattern is clear. This is the most useful thing about developing against a small local model: the failure modes you will eventually have to design around are loud here instead of rare. Whatever guardrails, validation, and grounding your application needs in production, you will notice you need them on day one. Resources [1] – POC code is here [2] – The picture was taken in Zakynthos, Greece
An AI agent is an "autonomous software program that uses artificial intelligence to make a plan, execute tools, and complete an action." Autonomy (through AI) plus tools is what makes an agent powerful, and these tools can range from a simple calculator to querying a sophisticated database. While autonomy makes things easier for humans, it only opens a big security hole. Because an agent can do almost anything, without proper guardrails, it can cause production outages. For example, in June 2025, a Replit agent deleted an entire database belonging to a SaaStr founder even though it was explicitly told to request permissions. This is why it is important to put proper guardrails around your agent. Guardrails can take the form of rules that prevent destructive tool calls or allow calls only to a specific list of domains. Another name for such a thing is "sandbox." A sandbox is a "security mechanism for separating running programs, usually in an effort to mitigate system failures and/or software vulnerabilities from spreading." There are multiple agent sandbox offerings. For example: Docker SandboxLangsmith SandboxOpen Sandbox Docker Sandbox Today, we will discuss Docker Sandbox. It has gained a lot of traction recently and plays an important role in Docker's AI strategy. Simply put, Docker Sandbox helps you run agents in an isolated environment. These sandboxes can run on your laptop or be hosted in the cloud. To understand how these sandboxes restrict agents, let's look at the architecture. First, there is a host machine. As we discussed earlier, this can be either your host or cloud infrastructure. When you launch a Docker Sandbox, it is primarily made up of two components. An AI agentDocker engine private to its own Sandbox. Each sandbox has its own Docker engine. There are multiple ways to implement a Sandbox architecture, but the industry is pivoting towards microVMs. This is primarily because of: Low resource overheadFaster bootup timesStrong isolationsReduced attack surfaceHyper-scaling A popular microVM tech stack involves Firecracker, Cloud hypervisors, and Kata containers. Now, coming to the communications, let's take a simple example. Imagine there is a hotel booking agent that searches and reserves hotels for customers. When a request comes in: Step 1: The agent makes a tool call to search for hotels. Step 2: The tool call leaves the micro VM to be verified against the Network policy. PowerShell sbx policy allow network api.search.foo.com sbx policy allow network api.search.bar.com Step 3: Above is an example of how you can define network policies for a Sandbox. In this case, we are allowing the agent to search under the foo domain and restrict its access to bar. Step 4: If the request comes for the foo domain, the network policy allows it to be forwarded to the particular website that serves the request. Let's look at how to create these Sandboxes on your laptop. Step 1: Installation PowerShell brew trust docker/tap brew install docker/tap/sbx Step 2: Sign in to your Docker account. PowerShell sbx login If you don't have one, you can create a free Docker account. Step 3: Run the sbx command. PowerShell sbx The first time you run it, you will see the following text: PowerShell Waiting for authentication... Signed in as sirivarma. Daemon started (PID: 58801, socket: /Users/svegiraju/Library/Application Support/com.docker.sandboxes/sandboxes/sandboxd/sandboxd.sock) Logs: /Users/svegiraju/Library/Application Support/com.docker.sandboxes/sandboxes/sandboxd/daemon.log Select a default network policy for your sandboxes: 1. Open — All network traffic allowed, no restrictions. ❯ 2. Balanced — Default deny, with common dev sites allowed. 3. Locked Down — All network traffic blocked unless you allow it. Use ↑/↓ or 1–3 to navigate, Enter to confirm, Esc to cancel. Network policy set to "Balanced". Default deny, with common dev sites allowed. I picked balanced so things like GitHub are on the allow list and everything else is denied by default. When you run sbx from a project folder, the sandbox has read/write permissions to everything in that folder. Step 4: Running sbx ls in a different shell will give the following output. PowerShell sbx ls PowerShell SANDBOX AGENT STATUS PORTS WORKSPACE docker-agent-svegiraju docker-agent running /Users/svegiraju Step 5: Running sbx policy ls will show the current list of allow and deny rules with the "Balanced" path we have chosen. PowerShell sbx policy ls PowerShell NAME TYPE DECISION RESOURCES default-ai-services network allow **.chatgpt.com:443 **.oaistatic.com:443 **.oaiusercontent.com:443 **.openai.com:443 api.anthropic.com:443 api.perplexity.ai:443 cdn.openaimerge.com:443 chatgpt.com:443 gemini.google.com:443 generativelanguage.googleapis.com:443 models.dev:443 nanoclaw.dev:443 platform.claude.com:443 play.googleapis.com:443 statsig.anthropic.com:443 default-package-managers network allow **.bun.sh:443 **.gradle.org:443 **.packagist.org:443 **.yarnpkg.com:443 apache.org:443 Step 6: In order to stop and remove the sandbox, issue the following commands PowerShell sbx stop my-sandbox sbx rm my-sandbox Step 7: Of course, you may want to diagnose issues within the sandbox or while running it. This is when we will use the "diagnose" command. PowerShell sbx diagnose Conclusion What we discussed so far shows why sandboxes matter and how to create one with Docker Sandbox. It is a free, open-source offering from Docker for individual use. In the upcoming blogs, we will discuss Cloud Sandboxes (running sandboxes in the cloud infrastructure instead of locally) and more complex configurations like Credentials, GPU passthrough, and environment files. All in all, Sandbox is an interesting project from Docker. Go try it out.
Introduction In my previous comparison of Genkit Go and ADK Go 2.0, I covered the everyday tasks. This article is about the agent layer, and instead of comparing features one by one, I did what you would actually do at work: I built something complex in one framework and then tried to build the same thing in the other. Same rules as always: every program here compiled and ran. Genkit Go v1.11.0 and ADK Go v2.1.0, gemini-3-flash-preview plus gpt-5-mini, 2026-08-05. Where I cite internals, they come from the released module sources. One caveat upfront so it frames everything: Genkit's agents API sits behind genkit.WithExperimental() and can change between minor releases, while ADK's agent stack is GA. That is a real ADK advantage. Everything else in this article is about what you can build. The Build: A Research Team You Can Fire and Forget Here is the spec. It is deliberately the kind of thing a real product asks for: An orchestrator agent that coordinates a researcher and a writer.The sub-agents save their work as files (findings.md, draft.md) that end up in one place.The system survives a model outage: if the orchestrator's primary model fails, it falls back to a different vendor.Dangerous tools need human approval before they run.The client fires the task and disconnects; the result waits server-side. In Genkit, this is one program of 90 lines, and it ran on the first try. The interesting part is that requirements 1, 2, and 3 are not code at all. They are three entries in a middleware list: Go orchestrator := genkitx.DefineAgent(g, "orchestrator", aix.InlinePrompt{ // The primary model is BROKEN on purpose. ai.WithModelName("googleai/gemini-nonexistent-model"), ai.WithSystem("You coordinate a team. First delegate research to the researcher, then delegate a short draft to the writer. Finally reply with a one-sentence status."), ai.WithUse( &middleware.Fallback{Models: []ai.ModelRef{ ai.NewModelRef("openai/gpt-5-mini", nil), // req 3: cross-vendor healing }, &middlewarex.Agents{ // req 1: the team Agents: []aix.AgentRef{researcher.Ref(), writer.Ref()}, MaxDelegations: 4, ArtifactStrategy: middlewarex.ArtifactStrategySession, }, &middlewarex.Artifacts{Readonly: true}, // req 2: read the team's files ), }, aix.WithSessionStore(localstore.NewInMemorySessionStore[any]()), ) The researcher and writer are each ~8 lines: an InlinePrompt with a Gemini model, a system prompt, and &middlewarex.Artifacts{} so they get write_artifact tools. Requirement 5 is the client side: Go conn.Send(&aix.AgentInput{ Message: ai.NewUserTextMessage("Research why Go suits AI agents, then have the writer produce a short draft."), Detach: true, }) out, _ := conn.Output() // returns immediately // later, from anywhere: snap, _ := orchestrator.GetSnapshot(ctx, out.SnapshotID) And the run, verbatim: Plain Text detached after 1ms, snapshot fc70253d-55f8-4890-a587-a6aa74d69438 settled: completed after 58s artifact "researcher_1/findings.md" (5041 chars) from researcher artifact "writer_2/draft.md" (3840 chars) from writer orchestrator: I delegated the research task to the researcher (findings.md saved) and the short draft to the writer (draft.md saved). Status: research and draft are complete and saved as researcher_1/findings.md and writer_2/draft.md. Read that output again, because a lot happened in those 58 seconds while no client was connected: the orchestrator's broken Gemini model failed on every turn and healed onto OpenAI every time; it delegated to two Gemini-powered sub-agents through auto-generated delegate_to_* tools; their artifacts were merged into the orchestrator's session with source metadata, and the whole thing settled into a snapshot I could read later from another process. A multi-vendor agent team, running unattended, in 90 lines. Why This Is Easy: Middleware Composes With Agents The capstone works because of one architectural decision: in Genkit, a middleware is a unit that can inject tools, wrap the model call, and wrap tool execution, and the same unit plugs into a plain Generate call or an agent's prompt with the same ai.WithUse(...) line. Capabilities become list entries. The shelf in v1.11.0's plugins/middleware: Retry, Fallback, ToolApproval, Filesystem (sandboxed file tools), Skills, plus the experimental Agents (delegation) and Artifacts. Two of them deserve their own demos, both of which I ran. Cross-Vendor Fallback Is Configuration The plugin ecosystem and the middleware system meet here. Any model from any plugin can be a fallback target for any other, so surviving a vendor outage is this, and nothing else: Go g := genkit.Init(ctx, genkit.WithPlugins( &googlegenai.GoogleAI{}, &openai.OpenAI{}, )) text, err := genkit.GenerateText(ctx, g, ai.WithModelName("googleai/gemini-nonexistent-model"), // broken ai.WithPrompt("Say hi in three words."), ai.WithUse( &middleware.Retry{MaxRetries: 1}, &middleware.Fallback{Models: []ai.ModelRef{ ai.NewModelRef("openai/gpt-5-mini", nil), }, ), ) My run: without the middleware, Error 404, Message: models/gemini-nonexistent-model is not found. With it: Hello there, friend. from OpenAI. 37 lines total, and the same two lines work inside an agent, which is exactly how the capstone's orchestrator heals itself. Human Approval Is Configuration, Too ToolApproval wraps tool execution and interrupts any tool not on its allowlist. The tool itself knows nothing about approvals: Go resp, _ := genkit.Generate(ctx, g, ai.WithPrompt("Delete the files in /tmp/scratch please."), ai.WithTools(deleteFiles), ai.WithUse(&middleware.ToolApproval{}), // nothing pre-approved ) // resp.FinishReason == "interrupted" part, _ := deleteFiles.RestartWith(p, ai.WithResumedMetadata[DeleteInput](map[string]any{"toolApproved": true})) final, _ := genkit.Generate(ctx, g, ai.WithMessages(resp.History()...), ai.WithToolRestarts(part), /* ... */) My run: finish reason: interrupted, then after approval, OK. I've deleted the files in /tmp/scratch. Governance became a deployment decision: you can add approval to an existing agent without touching a single tool. The Same Build in ADK 2.0, Piece by Piece Now the other side, requirement by requirement, with everything verified against the v2.1.0 sources or actually built. The team (req 1): ADK has this, GA. SubAgents with the auto-injected transfer_to_agent tool, or agenttool for function-call delegation. No complaints; this is ADK's home turf. Approval (req 4): ADK has this too, GA, and the declaration is even simpler. functiontool.Config{RequireConfirmation: true} is one flag. I ran the full round-trip in both frameworks and they land within one line of each other (102 vs 101 lines). The difference is the shape: ADK's wire flow is a synthetic adk_request_confirmation function call answered with a map[string]any{"confirmed": true} payload, while Genkit types the pause payload and the resume payload as Go structs and validates every resume against history. Pick your poison: a flag with an untyped protocol, or a typed protocol you wire yourself. Artifacts (req 2): partial. ADK has an artifact service (in-memory or GCS) and a loadartifactstool. What it does not have is the merging story: sub-agent artifacts flowing into the parent session with source attribution is what ArtifactStrategySession gave me for free. Cross-vendor fallback (req 3): you build it yourself, and it bites. ADK has no middleware and its callbacks cannot wrap the model call, so the seam is writing a custom model.LLM decorator. I built it: buffer the primary's output, and on failure call the backup. There is a trap here, and my first version hit it. The backup vendor rejected the call with: Plain Text "The requested model 'gemini-nonexistent-model' does not exist." ADK's LLMRequest carries the model name inside the request, so my OpenAI backup was asked to run the Gemini model. The decorator also has to rewrite the request: Go backupReq := *req backupReq.Model = f.backup.Name() After that fix, it worked (Hi there, friend.). Final tally: 86 lines of decorator and ceremony versus two lines of configuration, and the 86-line version is per-agent, static, one-level-deep, and has to handle mid-stream failure buffering itself. This is what "no middleware" costs in practice. Fire and forget (req 5): not possible. The v2.1.0 source is unambiguous. Runner.Run is a pull-based iterator: nothing executes unless the client consumes events; breaking the loop unwinds the stack, canceling the context aborts the work, and the REST server passes the request context straight into the run. There is no context.WithoutCancel anywhere in the runner or agent paths. ADK can park at an interrupt and survive a process restart by replaying the session event log, which is genuinely robust, but pausing at a boundary is not continuing to work after the client leaves. So the capstone scorecard: two requirements ADK meets as well or better (team, approval flag), one it meets partially (artifacts), one it makes you hand-build with sharp edges (fallback), and one it cannot do (detach). And each of the pieces it does have arrives through a different mechanism: a config field here, a toolset there, a plugin bundle, a hand-written decorator. Genkit's version is one composition model for all of them. The Pieces in Detail The capstone leaned on agent features that deserve their own numbers. All of these ran in both frameworks where both have them. Typed Session State A Genkit agent's state is a Go struct. A tool mutates it; every change streams to the client as a JSON-Patch, and the client reads it back typed: Go type GameState struct { Score int `json:"score"` Moves []string `json:"moves"` } // inside a tool if s := aix.SessionFromContext[GameState](ctx); s != nil { s.UpdateCustom(func(st GameState) GameState { st.Score += in.Points st.Moves = append(st.Moves, in.Move) return st }) } My run streamed [{"op":"replace","path":"","value":{"moves":["e4"],"score":10}] and conn.Custom() returned score=20 moves=[e4 Nf3] as a struct. Prompts read the same state with {{@state.score}. ADK's session state is Get(string) (any, error) / Set(string, any): a map, type assertions at every read, no schema. It works (my run: score=20 (int)), and instruction strings can template state with {score}, but nothing checks anything at compile time. The 2.0 graph engine does have typed parameter binding, but only for workflow function nodes, not for session state in a chat agent. Custom Agent Loops Both frameworks let you write a fully custom agent. In ADK you cannot implement the agent.Agent interface directly, since it is sealed and the compiler rejects any attempt with missing method internal; the sanctioned path is agent.New(agent.Config{Run: ...}), a full execution override, and it ran in my tests. The difference is what you write. Genkit's SessionRunner hands you a turn loop with history, per-turn tracing, snapshot writes, and panic recovery built in; my draft-then-self-critique agent is 57 lines. The ADK version of the same agent is 96 lines, because a Run override means producing raw session.Events: dig the question out of the event log, call the raw model layer, construct and yield events yourself. Agents Defined in .prompt Files DefinePromptAgent binds an agent to a .prompt file by name; the model, config and persona are data, not Go: Go pirate := genkitx.DefinePromptAgent(g, "pirate", aix.WithSessionStore[any](localstore.NewInMemorySessionStore[any]()), ) 26 lines of Go, and my run answered: Rewrite 'em in Python only if ye want yer agents movin' as slow as a barnacle-covered scow in a dead calm. Non-engineers edit the persona without touching code, and the same dotprompt format works across Genkit's other language SDKs. ADK's equivalent is an Instruction string or an InstructionProvider where you load files yourself; its YAML agent-config system exists but lives in an internal/ package only ADK's own CLI can reach. Where ADK's Agent Stack Is Ahead The list I would actually weigh on the other side, verified in the same source dive: GA stability, against everything above being on Genkit's experimental track.The graph engine as the runtime: every ADK agent runs as a node in the workflow engine, and the node kit (typed function nodes, routing, per-node schema validation, retries, nested graphs) is a public API.Schema-validated human input in workflows: a graph node can declare a JSON schema for the human's answer, validated on resume. For form-like HITL inside a pipeline, that is ahead of anything Genkit ships.Task isolation: ModeTask sub-agents get their own conversation scope with an auto-injected finish_task tool and scope-filtered history.Live audio: RunLive with modalities, speech config and transcription has no Genkit Go equivalent. The Scorecard Non-blank lines including imports. Every program ran. ExperimentGenkit GoADK Go 2.0The capstone: team + artifacts + fallback + detach90not buildable (fallback DIY, detach impossible)Cross-vendor fallback37 (configuration)86 (hand-written decorator, request-rewrite gotcha)Approval on an existing tool45 (middleware config)flag exists (GA), untyped wire protocolTyped state, 2 turns, live patches80, typed96, map + castsCustom draft-critique agent5796Agent from a .prompt file26no equivalentUndo the last exchange41no API: append-only event logContext caching40, one method calllifecycle DIY with a raw genai client Conclusion The agent layer is where Genkit's two architectural bets pay off together. The plugin ecosystem means models from different vendors, local runtimes, and vector stores are interchangeable references. The middleware system means capabilities (delegation, artifacts, fallback, retries, approval, sandboxed files, skills) are composable list entries that work identically in a one-shot generate and inside an agent. Stack those two on top of typed sessions, snapshots, and detach, and a fire-and-forget multi-vendor agent team is a 90-line program. I know because I ran it, and I also know what the same spec costs in ADK because I built the pieces: one requirement hand-written with real sharp edges, and one that cannot be built at all. ADK's agent stack remains the right pick when its strengths are your requirements: GA guarantees, graph workflows with schema validation, task isolation, live audio, and the Google Cloud platform around it. But if the question is "how hard is it to build something complex", the answer I measured is: in Genkit, complexity composes; in ADK, each capability is its own project. Further reading: Genkit Go vs ADK Go 2.0: A Hands-On Look at Boilerplate and Low-Level ControlGenkit Go basic-agents sampleGenkit middleware documentationADK 2.0 release notesGenkit GitHub repositoryADK Go GitHub repository
Cloud Architecture
Integration
Microservices
Performance
Beyond the Wall of Charts: How Advanced Observability Displaced the Telemetry Dashboard
October 22, 2026 by Prashant Maria
Google Cloud KMS vs. AWS KMS: Java Encryption Guide
October 22, 2026 by Sandeep Pal
When RabbitMQ Accepts a Message but Does Not Route It to a Queue
October 22, 2026 by Vamsi Krishna Gude
Frameworks
Java
JavaScript
Languages
Tools
Rebuilding Elasticsearch Under Load: Unsafe Watermarks and a Sampling Blind Spot
October 22, 2026 by Tarun Sharma
Idempotency Under Concurrency: Why Your Application Check Isn't the Last Line of Defense
October 22, 2026 by Ramesh Yara
Google Cloud KMS vs. AWS KMS: Java Encryption Guide
October 22, 2026 by Sandeep Pal
Deployment
DevOps and CI/CD
Maintenance
Monitoring and Observability
Beyond the Wall of Charts: How Advanced Observability Displaced the Telemetry Dashboard
October 22, 2026 by Prashant Maria
Google Cloud KMS vs. AWS KMS: Java Encryption Guide
October 22, 2026 by Sandeep Pal
When RabbitMQ Accepts a Message but Does Not Route It to a Queue
October 22, 2026 by Vamsi Krishna Gude
AI/ML
Java
JavaScript
Open Source
October 22, 2026
by Peter Verhas
CORE
October 22, 2026
by Ravi Kiran Mallidi
CORE