Store Service
Provide a generic, TTL-based in-memory key-value store that other microservices can use to put, get, pop, check, and delete arbitrary JSON values.
Examplestorekey-valuein-memoryredisttlcacherestapimicroservice
🌅 Horizon
Store Service at a Glance
Overview
Many systems need a small place to stash short-lived, loosely structured data — a hand-off payload between requests, a piece of work-in-progress state, a value another service will read back once — without standing up a purpose-built schema and database for each shape. This example models one generic Store service: callers put an arbitrary JSON value under a key with a time-to-live, and later get, pop, check, or delete it by that key.
Architecture
The service exposes a small internal REST surface over a single in-memory entity; every operation reads or writes exactly one item by its key.
What It Demonstrates
-
@datatype— a generic request and stored-item shape that carries an arbitrary JSON value alongside a key, an optional remark, and TTL-derived expiry. -
@database— an in-memory entity whose full operation set (put, get, pop, exists, delete) is generated automatically from a single declaration, including TTL-based expiry and an atomic pop, without hand-written queries or commands. -
@expression— a small conversion step plus thin pass-through logic that calls the generated entity operations directly. -
@api— a REST API taggedinternal, intended for consumption by other microservices rather than public clients. -
@configand@deploy— shared, imported configuration and a packaged Redis image used as the in-memory backing store.
Expected Result
You will put an arbitrary JSON value under a key with a TTL, read it back, pop it atomically, check whether a key still exists, and delete it — observing that a popped or deleted key no longer exists.
🧭 Voyage
1. Problem and Constraints
- A stored value is arbitrary JSON — the store does not constrain its shape.
- Every stored item carries a time-to-live; expiry is derived from it, not stored as a raw duration.
- A key uniquely identifies one item; putting the same key again overwrites it.
popmust read and remove an item as one atomic operation.- The API is internal: it exists to be called by other services, not end users.
2. Prerequisites
- The Ocean toolchain, at the version this example targets.
- Docker, to run the generated Redis database service.
- hurl, to run the included component test suite (optional).
3. Example Structure
Executable model files are grouped by DSL section:
0011-store-service/
├── store-datatype.ocn
├── store-config.ocn
├── in-mem-db.ocn
├── store-api.ocn
├── store-expressions.ocn
├── store-service.ocn
├── store-deploy.ocn
├── api/
│ └── *.hurl
└── example-info/
└── example-info.html
api/ holds a hurl component test suite covering the
store lifecycle, atomic pop, missing-key behavior, input validation,
and generic routing — documented further in
Verify the Result.
4. Model the Domain
StoreRequest is what a caller sends; StoreItem
is what the store persists and returns. Both carry the value as
untyped Json, so the store never needs to know a
caller's schema.
@datatype
StoreRequest
key: String
value: Json
remark: *String
ttlSeconds: Int
StoreItem
key: String
value: Json
remark: *String
expiresAt: *DateTime
A request carries a relative ttlSeconds; the stored item
instead carries an absolute expiresAt, computed once at
write time.
5. Persist Items with a Fully Generated Entity
The database is declared as an in-memory Redis-backed store. Naming
StoreItem as its only entity is enough: Ocean generates
the complete put, get, pop, exists, and delete operation set —
including TTL handling and an atomic pop — without any hand-written
query or command.
@database
Database StoreDb
type = in-memory
engine = redis
configType = StoreDbConfig
tags = in-memory
Entity StoreItem
# all PUT, GET, POP, EXISTS and DELETE endpoints are generated automatically
6. Define the API
Five REST endpoints map directly onto the generated entity
operations. The API is tagged internal and generates a
Swagger document for the services that will call it.
@api
StoreApi style: rest
engine = gin
configType = ApiConfig
tags = internal
version = 1.0.0
basePath = /
generateSwagger = true
post /store/put putData(req:StoreRequest) : String
get /store/get/{key} getData() : StoreItem
get /store/pop/{key} popData() : StoreItem
get /store/exists/{key} existsData() : Boolean
delete /store/delete/{key} deleteData() : _
The generated REST engine prefixes every route with the API's
major version, so /store/put is served at
/v1/store/put.
7. Convert and Bridge with Expressions
ConvertStoreRequestToStoreItem derives an absolute
expiry from the request's relative TTL using the built-in
TtlToExpire helper. Every other expression is a thin
pass-through into the entity operation StoreDb already
generated for StoreItem.
@expression
ConvertStoreRequestToStoreItem
input: r:StoreRequest
output: i:*StoreItem
logic
i.key = r.key
i.value = r.value
i.remark = r.remark
exp = TtlToExpire(r.ttlSeconds)
i.expiresAt = &exp
PutData
input: req: StoreRequest
output: out: String
logic
var s *StoreItem
s = ConvertStoreRequestToStoreItem(req)
out = StoreItem.PutStoreItemWithIdAndTtl(req.key, s, req.ttlSeconds)
GetData, PopData, ExistsData,
and DeleteData follow the same shape, each calling its
matching generated operation (GetStoreItem,
PopStoreItem, ExistsStoreItem,
DeleteStoreItem) by key.
8. Assemble the Service
StoreService
use config StoreConfig as cfg
impl api StoreApi as api on cfg.apiConfig.port
use database StoreDb as db
connect cfg.dbConfig -> db
use expression PutData
use expression GetData
use expression PopData
use expression ExistsData
use expression DeleteData
connect api.putData -> PutData
connect api.getData -> GetData
connect api.popData -> PopData
connect api.existsData -> ExistsData
connect api.deleteData -> DeleteData
Every API method connects straight to its expression; none of them touch the database directly, keeping the generated entity operations as the single place that talks to Redis.
9. Select Technologies and Configure Generation
The API is generated with a REST engine (gin) on a configurable port, the database uses a packaged Redis image, and the service depends on it at deploy time. Database connection settings — including retry and TLS behavior — live in configuration, separate from the model.
@config
StoreDbConfig
url: String (default=localhost:6379)
password: String (default=)
db: Int (default=0)
retriesNo: Int (default=10)
retriesDelaySec: Int (default=30)
tls: Boolean (default=false)
dialTimeoutSec: Int (default=5)
@deploy
@import service P.db.redis.docker@1.0.0 as RedisInMemDB
StoreDeploy
service StoreService
replica 1
export 9093:StoreService.api
dependsOn InMemStorDatabase
InMemStorDatabase
service RedisInMemDB
10. Validate, Generate, and Run
- From
0011-store-service/, validate and generate the example with the Ocean toolchain. - Start the generated Redis database and
StoreService; wait for a healthy/health/readyresponse on port9093.
11. Verify the Result
Put a value with a TTL, read it back, then pop it and confirm it is gone:
POST /v1/store/put
{"key":"demo-1","value":{"msg":"hello","n":42},"remark":"first item","ttlSeconds":120}
-> "demo-1"
GET /v1/store/get/demo-1
-> {"key":"demo-1","value":{"msg":"hello","n":42},"remark":"first item","expiresAt":"..."}
GET /v1/store/pop/demo-1
-> {"key":"demo-1","value":{"msg":"hello","n":42},"remark":"first item","expiresAt":"..."}
GET /v1/store/exists/demo-1
-> false
The api/ hurl suite automates this and more: the full
lifecycle (20_store_lifecycle.hurl), atomic pop
(30_store_pop.hurl), missing-key behavior
(40_store_not_found.hurl), input validation
(50_store_validation.hurl), and generic routing
(60_routing.hurl); run any of them with
hurl --test --variable base_url=http://localhost:9093 api/<file>.hurl.
12. Troubleshooting
getDataorpopDatareturns500 Internal Server Errorfor a key that was never stored: the underlying "not found" error from Redis does not currently match the generic error classifier'sNOT_FOUNDheuristics, so it falls through to a generic 500 instead of 404.existsDatais unaffected and correctly returnsfalse.putDatareturns500instead of400for a missing/empty key or a non-positivettlSeconds: only malformed JSON is rejected at the binding layer with400; other field-level validation currently surfaces as a generic500rather than a structured400.
13. Experiments and Extensions
- Put the same key twice with different values and confirm the second
putoverwrites the first. - Store TTLs are handled by Redis directly — try a short
ttlSecondsand confirmexistsDataturnsfalseonce it elapses, without ever callingdelete. - Tighten the error classifier so a Redis "not found" is recognized as
NOT_FOUND, and update the expected status in40_store_not_found.hurlfrom500to404. - Add structured field validation to
PutDataso an empty key or non-positivettlSecondsreturns400instead of500.
Executable model
<\> Implementation
Explore the runnable model by responsibility, then select a file to inspect its complete source.
# GET /info
GET {{base_url}}/info
HTTP 200
[Asserts]
jsonpath "$.id" == "SVC-STORE-SERVICE"
jsonpath "$.name" == "StoreService"
jsonpath "$.version" != null
jsonpath "$.version" != ""
jsonpath "$.address" != null
jsonpath "$.address" != ""
jsonpath "$.startedAt" != null
jsonpath "$.startedAt" != ""
# GET /health, /health/live, /health/ready
GET {{base_url}}/health
HTTP 200
[Asserts]
jsonpath "$.state" == "healthy"
jsonpath "$.startedAt" != null
jsonpath "$.startedAt" != ""
jsonpath "$.checkedAt" != null
jsonpath "$.checkedAt" != ""
jsonpath "$.checks" count > 0
GET {{base_url}}/health/live
HTTP 200
[Asserts]
jsonpath "$.state" == "healthy"
jsonpath "$.startedAt" != null
jsonpath "$.startedAt" != ""
jsonpath "$.checkedAt" != null
jsonpath "$.checkedAt" != ""
jsonpath "$.checks" count > 0
GET {{base_url}}/health/ready
HTTP 200
[Asserts]
jsonpath "$.state" == "healthy"
jsonpath "$.startedAt" != null
jsonpath "$.startedAt" != ""
jsonpath "$.checkedAt" != null
jsonpath "$.checkedAt" != ""
jsonpath "$.checks" count > 0
# Happy-path lifecycle: put -> get -> exists -> delete -> exists
POST {{base_url}}/v1/store/put
{
"key": "component-test-lifecycle-{{newUuid}}",
"value": {"msg": "hello", "n": 42},
"remark": "created by component test",
"ttlSeconds": 120
}
HTTP 200
[Captures]
key: jsonpath "$"
[Asserts]
jsonpath "$" != null
GET {{base_url}}/v1/store/get/{{key}}
HTTP 200
[Asserts]
jsonpath "$.key" == "{{key}}"
jsonpath "$.value.msg" == "hello"
jsonpath "$.value.n" == 42
jsonpath "$.remark" == "created by component test"
jsonpath "$.expiresAt" != null
jsonpath "$.expiresAt" != ""
GET {{base_url}}/v1/store/exists/{{key}}
HTTP 200
[Asserts]
jsonpath "$" == true
DELETE {{base_url}}/v1/store/delete/{{key}}
HTTP 204
[Asserts]
body == ""
GET {{base_url}}/v1/store/exists/{{key}}
HTTP 200
[Asserts]
jsonpath "$" == false
# delete is idempotent
DELETE {{base_url}}/v1/store/delete/{{key}}
HTTP 204
[Asserts]
body == ""
# pop removes the item atomically as it returns it
POST {{base_url}}/v1/store/put
{
"key": "component-test-pop-{{newUuid}}",
"value": ["a", "b", "c"],
"remark": null,
"ttlSeconds": 120
}
HTTP 200
[Captures]
key: jsonpath "$"
GET {{base_url}}/v1/store/pop/{{key}}
HTTP 200
[Asserts]
jsonpath "$.key" == "{{key}}"
jsonpath "$.value" count == 3
jsonpath "$.value[0]" == "a"
jsonpath "$.value[1]" == "b"
jsonpath "$.value[2]" == "c"
jsonpath "$.remark" == null
# Item must be gone after pop
GET {{base_url}}/v1/store/exists/{{key}}
HTTP 200
[Asserts]
jsonpath "$" == false
# Behavior when operating on a key that was never stored.
#
# NOTE: as of this writing, GetData/PopData surface a redis "not found"
# error whose message does not match any of the datatype.ClassifyError
# heuristics (it isn't recognized as a NOT_FOUND case), so it falls through
# to the generic internal-server-error mapping (HTTP 500) rather than 404.
# These entries document the currently observed behavior; if the error
# classification is tightened to map that case to NOT_FOUND, update the
# expected status here to 404.
GET {{base_url}}/v1/store/get/component-test-missing-{{newUuid}}
HTTP 500
GET {{base_url}}/v1/store/pop/component-test-missing-{{newUuid}}
HTTP 500
GET {{base_url}}/v1/store/exists/component-test-missing-{{newUuid}}
HTTP 200
[Asserts]
jsonpath "$" == false
DELETE {{base_url}}/v1/store/delete/component-test-missing-{{newUuid}}
HTTP 204
# Input validation around PutData.
# malformed JSON body: rejected directly by BindJSON, before service call
POST {{base_url}}/v1/store/put
```
{not-json
```
HTTP 400
[Asserts]
jsonpath "$.error" != null
jsonpath "$.error" != ""
# missing required field(s): body without a "key" at all
POST {{base_url}}/v1/store/put
{
"value": {"a": 1},
"ttlSeconds": 60
}
HTTP 500
# empty key
POST {{base_url}}/v1/store/put
{
"key": "",
"value": {"a": 1},
"remark": null,
"ttlSeconds": 60
}
HTTP 500
# non-positive ttlSeconds
POST {{base_url}}/v1/store/put
{
"key": "component-test-zero-ttl-{{newUuid}}",
"value": {"a": 1},
"remark": null,
"ttlSeconds": 0
}
HTTP 500
POST {{base_url}}/v1/store/put
{
"key": "component-test-neg-ttl-{{newUuid}}",
"value": {"a": 1},
"remark": null,
"ttlSeconds": -5
}
HTTP 500
# Generic router behavior not tied to a specific StoreApi handler.
GET {{base_url}}/does-not-exist
HTTP 404
OPTIONS {{base_url}}/v1/store/put
HTTP 204
[Asserts]
body == ""
# @ocean-meta-start
# tags:
# - key-value-store
# - in-memory
# - database
# - redis
# perspective:
# feature: store-service
# service: store-service
# @ocean-meta-end
@database
Database StoreDb
type = in-memory
engine = redis
configType = StoreDbConfig
tags = in-memory
Entity StoreItem
# all PUT, GET, POP, EXISTS and DELETE endpoints are generated automatically
# @ocean-meta-start
# tags:
# - store-api
# - rest-api
# perspective:
# feature: store-service
# service: store-service
# @ocean-meta-end
@api
StoreApi style: rest
engine = gin
configType = ApiConfig
tags = internal
version = 1.0.0
description = Provides in-memory store for other microservices
basePath = /
generateSwagger = true
# -------------------------
# endpoints
# -------------------------
post /store/put putData(req:StoreRequest) : String
get /store/get/{key} getData() : StoreItem
get /store/pop/{key} popData() : StoreItem
get /store/exists/{key} existsData() : Boolean
delete /store/delete/{key} deleteData() : _
# @ocean-meta-start
# tags:
# - configuration
# perspective:
# feature: store-service
# service: store-service
# @ocean-meta-end
@config
@import config O.log.config@1.0.0 as LogConfig
StoreConfig
apiConfig : ApiConfig
dbConfig : StoreDbConfig
logConfig : LogConfig
ApiConfig
port: Int (default=8080)
StoreDbConfig
url: String (default=localhost:6379)
password: String (default=)
db: Int (default=0)
retriesNo: Int (default=10)
retriesDelaySec: Int (default=30)
tls: Boolean (default=false)
dialTimeoutSec: Int (default=5)
# @ocean-meta-start
# tags:
# - datatype
# perspective:
# feature: store-service
# service: store-service
# @ocean-meta-end
@datatype
StoreRequest
key: String
value: Json
remark: *String
ttlSeconds: Int
StoreItem
key: String
value: Json
remark: *String
expiresAt: *DateTime
# @ocean-meta-start
# tags:
# - deployment
# - redis
# perspective:
# feature: store-service
# service: store-service
# @ocean-meta-end
@deploy
@import service P.db.redis.docker@1.0.0 as RedisInMemDB
Name: STR-deploy
StoreDeploy
service StoreService
replica 1
export 9093:StoreService.api
dependsOn InMemStorDatabase
InMemStorDatabase
service RedisInMemDB
# @ocean-meta-start
# tags:
# - expression
# perspective:
# feature: store-service
# service: store-service
# @ocean-meta-end
@expression
ConvertStoreRequestToStoreItem
input: r:StoreRequest
output: i:*StoreItem
logic
i.key = r.key
i.value = r.value
i.remark = r.remark
exp = TtlToExpire(r.ttlSeconds)
i.expiresAt = &exp
PutData
input: req: StoreRequest
output: out: String
logic
var s *StoreItem
s = ConvertStoreRequestToStoreItem(req)
out = StoreItem.PutStoreItemWithIdAndTtl(req.key, s, req.ttlSeconds)
GetData
input: key: String
output: out: StoreItem
logic
i = StoreItem.GetStoreItem(key)
out = *i
PopData
input: key: String
output: out: StoreItem
logic
i = StoreItem.PopStoreItem(key)
out = *i
ExistsData
input: key: String
output: out: Boolean
logic
e = StoreItem.ExistsStoreItem(key)
out = e
DeleteData
input: key: String
output: _
logic
StoreItem.DeleteStoreItem(key)
# @ocean-meta-start
# tags:
# - store-api
# - service
# perspective:
# feature: store-service
# service: store-service
# @ocean-meta-end
@service
StoreService
@perspectives: use:internal, microservice:generic
@tags: kinaxis, sce
use config StoreConfig as cfg
impl api StoreApi as api on cfg.apiConfig.port
use database StoreDb as db
# -------------------------
# Config binding
# -------------------------
# connect cfg.DbConfig -> MyDb
# -------------------------
# Config binding
# -------------------------
connect cfg.dbConfig -> db
# -------------------------
# Used Expressions
# -------------------------
use expression PutData
use expression GetData
use expression PopData
use expression ExistsData
use expression DeleteData
# -------------------------
# Init
# -------------------------
# init MyInitExpr
# -------------------------
# Connections
# -------------------------
connect api.putData -> PutData
connect api.getData -> GetData
connect api.popData -> PopData
connect api.existsData -> ExistsData
connect api.deleteData -> DeleteData