Krizaka
All building blocks

krizaka-platform-kit

Platform kit

The cross-cutting code every Spring Boot service writes — written once, with its invariants tested.

The problem it removes

An event published after the commit is lost on the next crash.

Publishing an event right after the database write: on a crash between the two, the event is lost; send it first and a rollback has already told the world. A failing @RabbitListener requeued forever blocks its queue. Every service invents its own error JSON. Across Orazaka's services this code existed five to seven times — with three live bugs between the copies.

  • Dual write: a service that writes its row then calls rabbitTemplate.convertAndSend loses the event when it crashes in between, and one that sends first announces changes that roll back.
  • Poison messages: a listener that throws on a malformed message is redelivered at once, forever, and nothing else in the queue moves.
  • Deduplication done by hand: a "does it exist? then insert" lets two overlapping deliveries both through — exactly when a broker redelivers a slow first attempt — and a claim kept after a failure silently drops the retry.
  • Security chains copied per service: two of six had drifted in Orazaka; one answered a CORS preflight with 401.

What it does

  • Events go through a transactional outbox: they commit or roll back with your data.
  • Queues retry 5 times, then dead-letter to .dlq — a poison message never loops.
  • One security baseline and RFC 9457 errors with a requestId, in every service.

Four modules and four starters: one security baseline for every filter chain, events through a transactional outbox, consumer queues that retry then dead-letter, idempotent consumption, RFC 9457 errors with a request id, and required service names on every metric.

Nothing is open by omission. SecurityBaseline ends every chain with anyRequest().authenticated(): the path you forgot to list is closed, not public.

The falcon's rule

Decisions and trade-offs

  1. We chose

    A transactional outbox: EventPublisher.publish writes a row of your outbox in your transaction; a relay claims rows with FOR UPDATE SKIP LOCKED and publishes them, with a messageId chosen at write time.

    We refused

    Publishing to the broker from the service method, and a distributed transaction (XA) across database and broker.

    Because

    The event commits or rolls back with the change. A relay that crashes republishes the same messageId, and the consumer's dedup makes it a no-op.

    What it costs you

    An outbox table per context (you implement OutboxStore over your schema) and a polling delay between commit and publish.

  2. We chose

    The envelope in AMQP headers (kz-type, kz-version, kz-producer, kz-correlation-id, kz-occurred-at), the body the bare event, and a JSON Schema per event in the producer's -api module.

    We refused

    A shared jar of event DTOs, and a JSON wrapper around every body.

    Because

    A shared DTO jar ties every service's release to every other's. A schema checked by tests on both sides (EventContractTest) gives the contract without the coupling, and the body stays readable by any language.

    What it costs you

    Each consumer keeps its own tolerant copy of the event and one contract test.

  3. We chose

    Quorum queues declared in one line (KrizakaQueues.consumer), a stateless retry with back-off — 5 attempts from 500 ms — then the queue’s .dlq twin with the original headers and the exception.

    We refused

    Spring's default requeue on failure.

    Because

    A requeued poison message loops forever; in the DLQ it is visible, with the reason, and can be replayed by hand.

    What it costs you

    A queue already declared as classic must be drained and redeclared once.

  4. We chose

    RFC 9457 Problem Details for every error: a DomainException becomes its 4xx with a stable code and the requestId; bean validation a 422 with errors[]; anything unexpected a 500 that never carries its message.

    We refused

    Free-form error bodies per service, and stack traces or exception messages in 500 responses.

    Because

    A client handles one shape, and the requestId in the response is the one in the log line — a support ticket points at the failure.

    What it costs you

    Your exceptions extend DomainException (NotFoundException, ConflictException…) to choose their status.

  5. We chose

    Starters that are POMs only, over modules usable alone; defaults applied at the lowest priority.

    We refused

    Renaming the modules into starters, and defaults that override application.yml.

    Because

    A product declares capabilities, not the Spring list behind them; a Spring Boot upgrade changes the starters, not your POM — and your application.yml always wins.

    What it costs you

    Java 21 and Spring Boot 4.0 only.

In code

javaUserService.java · UserEventsListener.java
@Transactional
public User register(NewUser input) {
  User user = users.save(input);
  // A row of YOUR outbox, in THIS transaction: no event for a rollback, no lost event on a crash.
  events.publish("evt.user.registered", 1, new UserRegistered(user.id(), user.email()));
  return user;
}

@Bean // the quorum queue, its <queue>.dlq, the bindings
Declarables userEvents(MessagingExchanges exchanges) {
  return KrizakaQueues.consumer(exchanges, "krizaka.notifications.user-events", "evt.user.registered");
}

@RabbitListener(queues = "krizaka.notifications.user-events") // 5 retries with back-off, then the DLQ
void onRegistered(UserRegistered event, @Header(AmqpHeaders.MESSAGE_ID) String messageId) {
  if (!dedup.claim("notifications.user-events", messageId)) return; // a redelivery: already done
  try { welcome(event); }
  catch (RuntimeException e) { dedup.release("notifications.user-events", messageId); throw e; }
}

Don't use it when

  • Your services run on Spring Boot 3 or Java 17 — the kit targets Spring Boot 4.0 and Java 21.
  • Your HTTP layer is WebFlux: krizaka-web configures servlet applications only.
  • Your bus is Kafka: krizaka-messaging is RabbitMQ (AMQP 0-9-1) only.
  • Your tokens come from an external identity provider signing with RS256 or ES256: krizaka-security verifies HS256 today.

Where it stands

0.2.0 on Maven Central (2026-10-10): the four modules and the four starters, built and tested on real PostgreSQL and RabbitMQ in CI.

Published

In progress

  • MessageDedup.processOnce — claim, run, give the claim back on failure. Five listeners in notifications and billing repeat those eight lines today; one of them documents the forgotten release as "the settlement is lost forever". Lands in 0.3.0.krizaka-platform-kit#12
  • One shared HS256 secret signs and verifies: a service that can verify can mint. Decided: issuer-signed session tokens (JWKS) and per-caller service tokens, HS256 deprecated over one minor.krizaka-platform-kit#11

Adopt it

xmlpom.xml
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.krizaka</groupId>
      <artifactId>krizaka-bom</artifactId>
      <version>0.2.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency><groupId>com.krizaka</groupId><artifactId>krizaka-spring-boot-starter-web</artifactId></dependency>
  <dependency><groupId>com.krizaka</groupId><artifactId>krizaka-spring-boot-starter-security</artifactId></dependency>
  <dependency><groupId>com.krizaka</groupId><artifactId>krizaka-spring-boot-starter-rabbitmq</artifactId></dependency>
  <dependency><groupId>com.krizaka</groupId><artifactId>krizaka-spring-boot-starter-observability</artifactId></dependency>
</dependencies>

Tell us where it hurts.

A block is right when it survives your code base, not ours. Ask in the block's thread, propose a change as an idea, or report a bug on its repository — every decision on this page is open to a better argument.

The other blocks