Skip to content

Design principles

Pulse is opinionated. The opinions are the product. This page collects the patterns the codebase uses repeatedly so contributors and integrators know when to reach for them — and when not to.

If you're proposing a new feature, every entry below is a question you should answer before opening the PR.


1. Core is opt-out. Niche is opt-in.

Pulse on the classpath = the day-one core is on. Cardinality firewall, timeout-budget propagation, async/Kafka context, trace-context guard, structured logs, exception fingerprints, and the diagnostic actuator ship with enabled: true. Users opt out of those with enabled: false.

Features that only make sense for a specific architecture are opt-in. Adding a starter must not extract a tenant from a client-supplied header, stamp profiler IDs on every span, silently change the prod sampling rate, or flip Kubernetes readiness on container memory. Those stay in the jar; they start when the operator sets pulse.<feature>.enabled=true (or, for classpath-gated integrations like Kafka / Hibernate / Resilience4j, when that library is already on the classpath).

This still rules out:

  • Annotations that have to be sprinkled to activate a feature (@EnableXxx). Spring already auto-configures everything; piling on more annotations tells users "we don't trust our defaults."
  • Per-bean opt-in. If you have to add @Observable to every controller, the feature isn't really "batteries included."

Classpath-conditional extras (Kafka time-lag, Hibernate N+1, Resilience4j, OpenFeature) remain "on when the library is present" except OpenFeature, which is opt-in even with the SDK on the classpath — flag values on MDC are a product choice, not a default.

2. Declarative-first, with an imperative escape hatch

YAML is the front door. A bean is the side door. Most users never need the side door, but it's always there.

The pattern is consistent:

pulse:
  some-feature:
    enabled-when:
      header-not-equals:
        client-id: test-client-id
      # OR — when declarative isn't enough:
      bean: myCustomMatcher

Why both?

  • Declarative wins on portability. Ops can change the rule in the config repo without rebuilding the app.
  • Bean wins on expressiveness. "Active only between 02:00–04:00 UTC for tenants on the free plan" will never fit a header-equals map, and inventing a DSL for it would be a worse API than just letting the user write Java.

If both are configured on the same block, the bean wins and Pulse logs a warning at startup. The user is told once, loudly, and the runtime is predictable.

3. Per-request gates use a single SPI: PulseRequestMatcher

Every feature that runs in the request scope and might want to skip some requests reuses the same primitive — a one-method PulseRequestMatcher interface compiled from the same enabled-when schema.

This buys:

  • One thing to learn. A user who configured enabled-when for trace-guard already knows how to configure it for timeout-budget, database, dependencies, fan-out, and exception fingerprints.
  • One place to test. Matcher logic lives in PulseRequestMatcherFactory. The feature filters get the compiled matcher and ask one question: gate.matches(request).
  • No accidental new vocabulary. New features that need a runtime gate get one for free; no new property names, no new mental model.

Features that don't fit (cardinality firewall on the meter pipeline, PII masking per log event, sampling per span) are explicitly NOT made to fit. They use the right native extension point — MeterFilter, log event masking, OpenTelemetry Sampler — even though that means the user has to learn a different (existing) API for those three.

4. Extension points are SPIs, not annotations

When a feature has a decision point that users want to override ("classify this dependency", "fingerprint this exception", "extract this tenant"), Pulse defines a single-method interface and auto-wires the user's @Bean if they declare one.

Decision SPI Default
Logical name for an outbound call DependencyClassifier Host-table lookup via pulse.dependencies.map
Stable id for an unhandled exception ErrorFingerprintStrategy SHA-256 over class + top stack frames
Tenant id for the inbound request TenantExtractor Header / JWT claim / subdomain (per config)
Should this feature run for this request PulseRequestMatcher Always-true (matches every request)
Add fields to the request context ContextContributor None — pure extension point

Why interfaces and not annotations? Annotations spread across the codebase make the answer to "where does the dep tag come from?" a global search. A @Bean DependencyClassifier is one file with one method; the answer is the bean.

5. Fail fast at startup, fail open at runtime

Every misconfiguration that can be detected at startup is detected at startup, with a clear message naming the property and the expected shape. Examples:

  • enabled-when.bean: foo where foo doesn't exist → IllegalStateException before the first request.
  • enabled-when.bean: foo where foo is the wrong type → same, with the actual type in the message.
  • pulse.slo.objectives with an unknown SLI name → startup fails with the list of valid names.

Once the app is running, Pulse defaults to fail open: a matcher that can't evaluate (no inbound request bound — e.g. inside a @Scheduled job that calls an outbound HTTP service) returns true so the feature runs. A ContextContributor that throws is logged once and skipped on subsequent requests. A meter filter that mis-tags overflows to the designated bucket.

The principle: never silently kill production observability because of a runtime edge case the user couldn't have known about at startup.

6. One bean per feature, gated by @ConditionalOnMissingBean

Every Pulse feature ships exactly one publicly-named bean, and every such bean carries @ConditionalOnMissingBean. Users override the whole feature by declaring their own bean of the same type — no Pulse-specific unregistration ritual, no system properties, no profile gymnastics.

The corollary: Pulse's bean class is a public class with a public constructor. Whatever wiring Pulse does, the user can replicate or delegate to.

7. Cardinality is a first-class concern

Every metric tag is justified before it ships. Three rules:

  1. Bounded by something the operator already knows. Endpoint = Spring route template (already bounded by your controllers). Dependency = entry from pulse.dependencies.map (already bounded by your config). Tenant = pulse.tenant.max-tag-cardinality (explicitly capped, with overflow bucket).
  2. The cardinality firewall is on by default for any meter prefix Pulse owns. Excess values bucket to OVERFLOW and a one-time WARN log line fires.
  3. No userId tag, ever. No requestId tag, ever. No timestamp tag, ever. Those go on logs and traces — never on metrics.

8. The /actuator/pulse endpoint is the source of truth

If a feature exists, the actuator endpoint reports its current configuration and observed health. "What is Pulse doing in this pod?" is a single HTTP call away — no log scraping, no JMX trees, no classpath-introspection scripts.

This shapes design: a feature that can't faithfully report itself isn't done. The endpoint is what makes Pulse legible to the operator.

9. Honesty about what we don't do

Each feature page has a "When to skip it" section. Pulse refuses to pretend its defaults are universally right. The starter ships with opinions, and one of those opinions is that you should turn things off when they don't apply to your workload.

The same honesty applies to scope. Pulse is a starter, not a platform: it bundles best-practice defaults for the OpenTelemetry + Micrometer + Spring stack. It does not ship a collector, a backend, a storage layer, or a query language.


Adopting these principles

If you're adding a new feature to Pulse, the checklist is:

  • pulse.<feature>.enabled exists. Core features default to true; architecture-specific features default to false and document how to turn them on. Classpath-gated integrations (@ConditionalOnClass) stay on when the library is present unless they have a side effect on a generic Spring app.
  • If the feature is per-request, enabled-when is wired through PulseRequestMatcherFactory.
  • Decision points are SPIs (@FunctionalInterface + default impl), not annotations.
  • The bean is @ConditionalOnMissingBean so users can override it.
  • Misconfiguration fails at startup; runtime edge cases fail open.
  • Every metric tag is justified in the property javadoc.
  • /actuator/pulse reports the feature's configuration and health.
  • The docs page leads with the problem, then "what you get", then config, then "when to skip it".

If you're integrating Pulse and want to extend it, the corresponding checklist is:

  • Override declarative behaviour via application.yml first.
  • When that isn't enough, drop in an SPI bean.
  • When that isn't enough, declare a @Bean of the Pulse type and Pulse's default disappears via @ConditionalOnMissingBean.

You will rarely reach step 3.