This is an automated email from the ASF dual-hosted git repository.

DImuthuUpe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit 43301854ed103a7f02a815d31bade78ce2f48574
Author: DImuthuUpe <[email protected]>
AuthorDate: Sat May 16 20:10:11 2026 -0400

    Framework implement to REST API and DB integration
---
 build_output.txt                                   |   0
 cmd/server/main.go                                 | 131 +++++++++
 docs/API-Docs.md                                   | 319 +++++++++++++++++++++
 go.mod                                             |  12 +
 go.sum                                             |  73 +++++
 internal/db/db.go                                  |  46 +++
 internal/db/embed.go                               |  23 ++
 internal/db/migrate.go                             |  55 ++++
 .../db/migrations/000001_initial_schema.down.sql   |  20 ++
 .../db/migrations/000001_initial_schema.up.sql     |  60 ++++
 internal/db/tx.go                                  |  46 +++
 internal/server/server.go                          | 194 +++++++++++++
 internal/store/organization_store.go               |  82 ++++++
 internal/store/project_store.go                    |  97 +++++++
 internal/store/store.go                            |  71 +++++
 internal/store/user_store.go                       |  97 +++++++
 pkg/README.md                                      | 120 ++++++++
 pkg/models/project.go                              |  30 +-
 pkg/service/errors.go                              |  34 +++
 pkg/service/organization.go                        | 106 +++++++
 pkg/service/project.go                             | 127 ++++++++
 pkg/service/service.go                             |  73 +++++
 pkg/service/user.go                                | 122 ++++++++
 23 files changed, 1923 insertions(+), 15 deletions(-)

diff --git a/build_output.txt b/build_output.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmd/server/main.go b/cmd/server/main.go
new file mode 100644
index 000000000..08f9dd748
--- /dev/null
+++ b/cmd/server/main.go
@@ -0,0 +1,131 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Command server starts the Custos HTTP API.
+package main
+
+import (
+       "context"
+       "errors"
+       "log/slog"
+       "net/http"
+       "os"
+       "os/signal"
+       "strconv"
+       "syscall"
+       "time"
+
+       "github.com/apache/airavata-custos/internal/db"
+       "github.com/apache/airavata-custos/internal/server"
+       "github.com/apache/airavata-custos/pkg/service"
+)
+
+func main() {
+       slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
+
+       if err := run(); err != nil {
+               slog.Error("server exited with error", "error", err)
+               os.Exit(1)
+       }
+}
+
+func run() error {
+       dsn := os.Getenv("DATABASE_DSN")
+       if dsn == "" {
+               return errors.New("DATABASE_DSN environment variable is 
required " +
+                       "(e.g. 
user:pass@tcp(localhost:3306)/custos?parseTime=true&charset=utf8mb4)")
+       }
+
+       addr := envDefault("HTTP_ADDR", ":8080")
+       maxOpen := envInt("DB_MAX_OPEN_CONNS", 25)
+       maxIdle := envInt("DB_MAX_IDLE_CONNS", 5)
+
+       database, err := db.Open(db.Config{
+               DSN:          dsn,
+               MaxOpenConns: maxOpen,
+               MaxIdleConns: maxIdle,
+       })
+       if err != nil {
+               return err
+       }
+       defer database.Close()
+
+       if err := db.MigrateEmbedded(database); err != nil {
+               return err
+       }
+
+       svc := service.New(database)
+       handler := server.LoggingMiddleware(server.New(svc))
+
+       httpServer := &http.Server{
+               Addr:              addr,
+               Handler:           handler,
+               ReadHeaderTimeout: 10 * time.Second,
+               ReadTimeout:       30 * time.Second,
+               WriteTimeout:      30 * time.Second,
+               IdleTimeout:       120 * time.Second,
+       }
+
+       ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, 
syscall.SIGTERM)
+       defer stop()
+
+       serverErr := make(chan error, 1)
+       go func() {
+               slog.Info("http server listening", "addr", addr)
+               if err := httpServer.ListenAndServe(); err != nil && 
!errors.Is(err, http.ErrServerClosed) {
+                       serverErr <- err
+               }
+               close(serverErr)
+       }()
+
+       select {
+       case <-ctx.Done():
+               slog.Info("shutdown signal received")
+       case err := <-serverErr:
+               if err != nil {
+                       return err
+               }
+       }
+
+       shutdownCtx, cancel := context.WithTimeout(context.Background(), 
15*time.Second)
+       defer cancel()
+       if err := httpServer.Shutdown(shutdownCtx); err != nil {
+               return err
+       }
+       slog.Info("server stopped cleanly")
+       return nil
+}
+
+func envDefault(key, fallback string) string {
+       if v := os.Getenv(key); v != "" {
+               return v
+       }
+       return fallback
+}
+
+func envInt(key string, fallback int) int {
+       v := os.Getenv(key)
+       if v == "" {
+               return fallback
+       }
+       n, err := strconv.Atoi(v)
+       if err != nil {
+               slog.Warn("invalid integer env var, using default", "key", key, 
"value", v, "default", fallback)
+               return fallback
+       }
+       return n
+}
diff --git a/docs/API-Docs.md b/docs/API-Docs.md
new file mode 100644
index 000000000..dacb5364d
--- /dev/null
+++ b/docs/API-Docs.md
@@ -0,0 +1,319 @@
+# Custos API Documentation
+
+HTTP/JSON API exposed by `cmd/server`. All endpoints accept and return
+`application/json` and use UTF-8.
+
+- **Base URL:** `http://<host>:<port>` (default port `8080`, configurable via 
`HTTP_ADDR`)
+- **Auth:** none currently enforced (deploy behind a trusted ingress / auth 
proxy)
+- **Content-Type:** `application/json` is required on every request that has a 
body
+- **Unknown fields:** request bodies with unknown JSON fields are rejected 
with `400`
+
+---
+
+## Conventions
+
+### Identifiers
+
+- `id` fields are server-generated UUIDs when omitted from a create request.
+- `originated_id` is an optional external identifier (e.g. ACCESS Record ID) — 
when supplied, it must be unique within its entity type.
+
+### Timestamps
+
+All timestamps are RFC 3339 / ISO 8601 with timezone, e.g. 
`2026-05-16T12:34:56.789Z`. The server emits UTC.
+
+### Error format
+
+Errors are returned with an appropriate HTTP status code and a JSON body:
+
+```json
+{ "error": "human-readable message" }
+```
+
+| Status | Meaning | Triggered by |
+|--------|---------|--------------|
+| `400 Bad Request` | Malformed JSON, unknown field, missing required field, 
or unknown foreign-key reference | request body validation, 
`service.ErrInvalidInput` |
+| `404 Not Found` | Requested record does not exist | `service.ErrNotFound` |
+| `409 Conflict` | Duplicate `email` or duplicate `originated_id` | 
`service.ErrAlreadyExists` |
+| `500 Internal Server Error` | Unexpected server / database failure (driver 
message is logged, never returned) | any other error |
+
+---
+
+## Health
+
+### `GET /healthz`
+
+Liveness probe. Always returns `200` when the process is accepting connections.
+
+**Response 200**
+
+```json
+{ "status": "ok" }
+```
+
+---
+
+## Organizations
+
+### `POST /organizations`
+
+Create a new organization.
+
+**Required fields:** `name`
+**Optional fields:** `id` (auto-generated if omitted), `originated_id`
+
+**Request**
+
+```json
+{
+  "name": "University of Example",
+  "originated_id": "ACCESS-ORG-001"
+}
+```
+
+**Response 201**
+
+```json
+{
+  "id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
+  "originated_id": "ACCESS-ORG-001",
+  "name": "University of Example"
+}
+```
+
+**Errors**
+
+- `400` — `name` is required.
+- `409` — an organization with the supplied `originated_id` already exists.
+
+#### Example
+
+```bash
+curl -s -X POST http://localhost:8080/organizations \
+  -H 'Content-Type: application/json' \
+  -d '{"name":"University of Example","originated_id":"ACCESS-ORG-001"}'
+```
+
+---
+
+### `GET /organizations/{id}`
+
+Retrieve an organization by its ID.
+
+**Response 200**
+
+```json
+{
+  "id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
+  "originated_id": "ACCESS-ORG-001",
+  "name": "University of Example"
+}
+```
+
+**Errors**
+
+- `404` — no organization matches the supplied ID.
+
+---
+
+## Users
+
+### `POST /users`
+
+Create a new user.
+
+**Required fields:** `organization_id`, `email`
+**Optional fields:** `id`, `first_name`, `last_name`, `middle_name`
+
+The referenced `organization_id` must already exist; emails must be unique.
+
+**Request**
+
+```json
+{
+  "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
+  "first_name": "Ada",
+  "last_name": "Lovelace",
+  "email": "[email protected]"
+}
+```
+
+**Response 201**
+
+```json
+{
+  "id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02",
+  "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
+  "first_name": "Ada",
+  "last_name": "Lovelace",
+  "email": "[email protected]"
+}
+```
+
+**Errors**
+
+- `400` — `email`, `organization_id` missing, or `organization_id` does not 
exist.
+- `409` — a user with this `email` already exists.
+
+#### Example
+
+```bash
+curl -s -X POST http://localhost:8080/users \
+  -H 'Content-Type: application/json' \
+  -d '{
+        "organization_id":"8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
+        "first_name":"Ada",
+        "last_name":"Lovelace",
+        "email":"[email protected]"
+      }'
+```
+
+---
+
+### `GET /users/{id}`
+
+Retrieve a user by its ID.
+
+**Response 200**
+
+```json
+{
+  "id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02",
+  "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
+  "first_name": "Ada",
+  "last_name": "Lovelace",
+  "email": "[email protected]"
+}
+```
+
+**Errors**
+
+- `404` — no user matches the supplied ID.
+
+---
+
+## Projects
+
+### `POST /projects`
+
+Create a new project.
+
+**Required fields:** `title`, `project_pi_id`
+**Optional fields:** `id`, `origination`, `originated_id`, `created_time` 
(defaults to current UTC time)
+
+The referenced `project_pi_id` must be an existing user. `originated_id`, when 
supplied, must be unique across projects.
+
+**Request**
+
+```json
+{
+  "title": "Climate Simulation 2026",
+  "origination": "ACCESS",
+  "originated_id": "ACCESS-PRJ-9000",
+  "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02"
+}
+```
+
+**Response 201**
+
+```json
+{
+  "id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1",
+  "originated_id": "ACCESS-PRJ-9000",
+  "title": "Climate Simulation 2026",
+  "origination": "ACCESS",
+  "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02",
+  "created_time": "2026-05-16T17:21:04.512Z"
+}
+```
+
+**Errors**
+
+- `400` — `title`, `project_pi_id` missing, or the PI user does not exist.
+- `409` — a project with this `originated_id` already exists.
+
+#### Example
+
+```bash
+curl -s -X POST http://localhost:8080/projects \
+  -H 'Content-Type: application/json' \
+  -d '{
+        "title":"Climate Simulation 2026",
+        "origination":"ACCESS",
+        "originated_id":"ACCESS-PRJ-9000",
+        "project_pi_id":"f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02"
+      }'
+```
+
+---
+
+### `GET /projects/{id}`
+
+Retrieve a project by its ID.
+
+**Response 200**
+
+```json
+{
+  "id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1",
+  "originated_id": "ACCESS-PRJ-9000",
+  "title": "Climate Simulation 2026",
+  "origination": "ACCESS",
+  "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02",
+  "created_time": "2026-05-16T17:21:04.512Z"
+}
+```
+
+**Errors**
+
+- `404` — no project matches the supplied ID.
+
+---
+
+## End-to-end example
+
+```bash
+BASE=http://localhost:8080
+
+ORG_ID=$(curl -s -X POST $BASE/organizations \
+  -H 'Content-Type: application/json' \
+  -d '{"name":"University of Example","originated_id":"ACCESS-ORG-001"}' \
+  | jq -r .id)
+
+USER_ID=$(curl -s -X POST $BASE/users \
+  -H 'Content-Type: application/json' \
+  -d 
"{\"organization_id\":\"$ORG_ID\",\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"email\":\"[email protected]\"}"
 \
+  | jq -r .id)
+
+PROJ_ID=$(curl -s -X POST $BASE/projects \
+  -H 'Content-Type: application/json' \
+  -d "{\"title\":\"Climate Simulation 
2026\",\"origination\":\"ACCESS\",\"originated_id\":\"ACCESS-PRJ-9000\",\"project_pi_id\":\"$USER_ID\"}"
 \
+  | jq -r .id)
+
+curl -s $BASE/projects/$PROJ_ID | jq
+```
+
+---
+
+## Running the server
+
+```bash
+export 
DATABASE_DSN='custos:secret@tcp(127.0.0.1:3306)/custos?parseTime=true&charset=utf8mb4'
+# optional
+export HTTP_ADDR=:8080
+export DB_MAX_OPEN_CONNS=25
+export DB_MAX_IDLE_CONNS=5
+
+go run ./cmd/server
+```
+
+| Environment variable | Default | Purpose |
+|----------------------|---------|---------|
+| `DATABASE_DSN` | *(required)* | MySQL/MariaDB DSN. `parseTime=true` is 
mandatory. |
+| `HTTP_ADDR` | `:8080` | Address the HTTP server binds to. |
+| `DB_MAX_OPEN_CONNS` | `25` | Maximum open database connections. |
+| `DB_MAX_IDLE_CONNS` | `5` | Maximum idle database connections. |
+
+Migrations from `internal/db/migrations/` are applied automatically on startup.
+
+The server handles `SIGINT` / `SIGTERM` gracefully, draining in-flight requests
+for up to 15 seconds before exiting.
diff --git a/go.mod b/go.mod
new file mode 100644
index 000000000..33f3b3641
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,12 @@
+module github.com/apache/airavata-custos
+
+go 1.24.0
+
+require (
+       github.com/go-sql-driver/mysql v1.8.1
+       github.com/golang-migrate/migrate/v4 v4.19.1
+       github.com/google/uuid v1.6.0
+       github.com/jmoiron/sqlx v1.4.0
+)
+
+require filippo.io/edwards25519 v1.1.1 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 000000000..77f8f3160
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,73 @@
+filippo.io/edwards25519 v1.1.0/go.mod 
h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
+filippo.io/edwards25519 v1.1.1/go.mod 
h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 
h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
+github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod 
h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Microsoft/go-winio v0.6.2 
h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod 
h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/containerd/errdefs v1.0.0 
h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod 
h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 
h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod 
h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc 
h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod 
h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
+github.com/dhui/dktest v0.4.6/go.mod 
h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
+github.com/distribution/reference v0.6.0 
h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod 
h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v28.3.3+incompatible 
h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
+github.com/docker/docker v28.3.3+incompatible/go.mod 
h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.5.0 
h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
+github.com/docker/go-connections v0.5.0/go.mod 
h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
+github.com/docker/go-units v0.5.0 
h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod 
h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/felixge/httpsnoop v1.0.4 
h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod 
h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod 
h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod 
h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-sql-driver/mysql v1.8.1 
h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
+github.com/go-sql-driver/mysql v1.8.1/go.mod 
h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod 
h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-migrate/migrate/v4 v4.19.1 
h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
+github.com/golang-migrate/migrate/v4 v4.19.1/go.mod 
h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod 
h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
+github.com/jmoiron/sqlx v1.4.0/go.mod 
h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod 
h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mattn/go-sqlite3 v1.14.22 
h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod 
h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/moby/docker-image-spec v1.3.1 
h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod 
h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
+github.com/moby/term v0.5.0/go.mod 
h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod 
h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
+github.com/opencontainers/go-digest v1.0.0 
h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod 
h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.0 
h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
+github.com/opencontainers/image-spec v1.1.0/go.mod 
h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod 
h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 
h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod 
h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.10.0 
h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod 
h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+go.opentelemetry.io/auto/sdk v1.1.0 
h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod 
h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 
h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod 
h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
+go.opentelemetry.io/otel v1.37.0 
h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod 
h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/metric v1.37.0 
h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod 
h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/trace v1.37.0 
h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod 
h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 000000000..a662d5467
--- /dev/null
+++ b/internal/db/db.go
@@ -0,0 +1,46 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package db
+
+import (
+       "fmt"
+
+       _ "github.com/go-sql-driver/mysql"
+       "github.com/jmoiron/sqlx"
+)
+
+// Config holds configuration for opening a database connection.
+type Config struct {
+       DSN          string
+       MaxOpenConns int
+       MaxIdleConns int
+}
+
+// Open opens and validates a MySQL/MariaDB connection using the supplied 
config.
+func Open(cfg Config) (*sqlx.DB, error) {
+       db, err := sqlx.Open("mysql", cfg.DSN)
+       if err != nil {
+               return nil, fmt.Errorf("open database: %w", err)
+       }
+       db.SetMaxOpenConns(cfg.MaxOpenConns)
+       db.SetMaxIdleConns(cfg.MaxIdleConns)
+       if err := db.Ping(); err != nil {
+               return nil, fmt.Errorf("ping database: %w", err)
+       }
+       return db, nil
+}
diff --git a/internal/db/embed.go b/internal/db/embed.go
new file mode 100644
index 000000000..e8cbfaa7e
--- /dev/null
+++ b/internal/db/embed.go
@@ -0,0 +1,23 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package db
+
+import "embed"
+
+//go:embed migrations/*.sql
+var migrationFS embed.FS
diff --git a/internal/db/migrate.go b/internal/db/migrate.go
new file mode 100644
index 000000000..446c26b9e
--- /dev/null
+++ b/internal/db/migrate.go
@@ -0,0 +1,55 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package db
+
+import (
+       "errors"
+       "fmt"
+       "log/slog"
+
+       "github.com/golang-migrate/migrate/v4"
+       "github.com/golang-migrate/migrate/v4/database/mysql"
+       "github.com/golang-migrate/migrate/v4/source/iofs"
+       "github.com/jmoiron/sqlx"
+)
+
+// MigrateEmbedded applies all pending migrations from the embedded
+// migrations/ directory against the supplied database.
+// Returns nil when there is nothing to apply.
+func MigrateEmbedded(database *sqlx.DB) error {
+       driver, err := mysql.WithInstance(database.DB, &mysql.Config{})
+       if err != nil {
+               return fmt.Errorf("create migration driver: %w", err)
+       }
+
+       source, err := iofs.New(migrationFS, "migrations")
+       if err != nil {
+               return fmt.Errorf("create migration source: %w", err)
+       }
+
+       m, err := migrate.NewWithInstance("iofs", source, "mysql", driver)
+       if err != nil {
+               return fmt.Errorf("create migrator: %w", err)
+       }
+
+       if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
+               return fmt.Errorf("run migrations: %w", err)
+       }
+       slog.Info("database migrations applied successfully")
+       return nil
+}
diff --git a/internal/db/migrations/000001_initial_schema.down.sql 
b/internal/db/migrations/000001_initial_schema.down.sql
new file mode 100644
index 000000000..05fe180a5
--- /dev/null
+++ b/internal/db/migrations/000001_initial_schema.down.sql
@@ -0,0 +1,20 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements.  See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership.  The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License.  You may obtain a copy of the License at
+--
+--   http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied.  See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+DROP TABLE IF EXISTS projects;
+DROP TABLE IF EXISTS users;
+DROP TABLE IF EXISTS organizations;
diff --git a/internal/db/migrations/000001_initial_schema.up.sql 
b/internal/db/migrations/000001_initial_schema.up.sql
new file mode 100644
index 000000000..2b8872db9
--- /dev/null
+++ b/internal/db/migrations/000001_initial_schema.up.sql
@@ -0,0 +1,60 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements.  See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership.  The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License.  You may obtain a copy of the License at
+--
+--   http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied.  See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+SET NAMES utf8mb4;
+SET time_zone = '+00:00';
+
+CREATE TABLE IF NOT EXISTS organizations
+(
+    id            VARCHAR(255) NOT NULL,
+    originated_id VARCHAR(255) NOT NULL,
+    name          VARCHAR(255) NOT NULL,
+    created_at    TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+    updated_at    TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE 
CURRENT_TIMESTAMP(6),
+    PRIMARY KEY (id),
+    KEY idx_organizations_originated_id (originated_id)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS users
+(
+    id              VARCHAR(255) NOT NULL,
+    organization_id VARCHAR(255) NOT NULL,
+    first_name      VARCHAR(255) NOT NULL,
+    last_name       VARCHAR(255) NOT NULL,
+    middle_name     VARCHAR(255) NULL,
+    email           VARCHAR(255) NOT NULL,
+    created_at      TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+    updated_at      TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON 
UPDATE CURRENT_TIMESTAMP(6),
+    PRIMARY KEY (id),
+    UNIQUE KEY uq_users_email (email),
+    KEY idx_users_organization_id (organization_id),
+    CONSTRAINT fk_users_organization FOREIGN KEY (organization_id) REFERENCES 
organizations (id) ON DELETE RESTRICT
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS projects
+(
+    id            VARCHAR(255) NOT NULL,
+    originated_id VARCHAR(255) NOT NULL,
+    title         VARCHAR(255) NOT NULL,
+    origination   VARCHAR(255) NOT NULL,
+    project_pi_id VARCHAR(255) NOT NULL,
+    created_time  TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+    PRIMARY KEY (id),
+    KEY idx_projects_originated_id (originated_id),
+    KEY idx_projects_pi (project_pi_id),
+    CONSTRAINT fk_projects_pi FOREIGN KEY (project_pi_id) REFERENCES users 
(id) ON DELETE RESTRICT
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
diff --git a/internal/db/tx.go b/internal/db/tx.go
new file mode 100644
index 000000000..050c06da8
--- /dev/null
+++ b/internal/db/tx.go
@@ -0,0 +1,46 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package db
+
+import (
+       "context"
+       "database/sql"
+       "fmt"
+
+       "github.com/jmoiron/sqlx"
+)
+
+// TxFn executes fn within a new database transaction.
+// If fn returns an error, the transaction is rolled back.
+// If fn returns nil, the transaction is committed.
+func TxFn(ctx context.Context, db *sqlx.DB, fn func(tx *sql.Tx) error) error {
+       tx, err := db.BeginTx(ctx, nil)
+       if err != nil {
+               return fmt.Errorf("begin transaction: %w", err)
+       }
+       if err := fn(tx); err != nil {
+               if rbErr := tx.Rollback(); rbErr != nil {
+                       return fmt.Errorf("rollback failed: %v (original error: 
%w)", rbErr, err)
+               }
+               return err
+       }
+       if err := tx.Commit(); err != nil {
+               return fmt.Errorf("commit transaction: %w", err)
+       }
+       return nil
+}
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 000000000..bc9e9746a
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,194 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package server exposes the pkg/service API over HTTP/JSON.
+package server
+
+import (
+       "encoding/json"
+       "errors"
+       "log/slog"
+       "net/http"
+       "strings"
+       "time"
+
+       "github.com/apache/airavata-custos/pkg/models"
+       "github.com/apache/airavata-custos/pkg/service"
+)
+
+// Server is an HTTP handler that exposes the service API.
+type Server struct {
+       svc *service.Service
+       mux *http.ServeMux
+}
+
+// New builds an HTTP handler wired to the supplied service.
+func New(svc *service.Service) *Server {
+       s := &Server{svc: svc, mux: http.NewServeMux()}
+       s.routes()
+       return s
+}
+
+// ServeHTTP satisfies http.Handler.
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+       s.mux.ServeHTTP(w, r)
+}
+
+func (s *Server) routes() {
+       s.mux.HandleFunc("GET /healthz", s.healthz)
+
+       s.mux.HandleFunc("POST /organizations", s.createOrganization)
+       s.mux.HandleFunc("GET /organizations/{id}", s.getOrganization)
+
+       s.mux.HandleFunc("POST /users", s.createUser)
+       s.mux.HandleFunc("GET /users/{id}", s.getUser)
+
+       s.mux.HandleFunc("POST /projects", s.createProject)
+       s.mux.HandleFunc("GET /projects/{id}", s.getProject)
+}
+
+func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
+       writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+func (s *Server) createOrganization(w http.ResponseWriter, r *http.Request) {
+       var org models.Organization
+       if err := decodeJSON(r, &org); err != nil {
+               writeError(w, http.StatusBadRequest, err)
+               return
+       }
+       created, err := s.svc.CreateOrganization(r.Context(), &org)
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusCreated, created)
+}
+
+func (s *Server) getOrganization(w http.ResponseWriter, r *http.Request) {
+       org, err := s.svc.GetOrganization(r.Context(), r.PathValue("id"))
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusOK, org)
+}
+
+func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
+       var u models.User
+       if err := decodeJSON(r, &u); err != nil {
+               writeError(w, http.StatusBadRequest, err)
+               return
+       }
+       created, err := s.svc.CreateUser(r.Context(), &u)
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusCreated, created)
+}
+
+func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
+       u, err := s.svc.GetUser(r.Context(), r.PathValue("id"))
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusOK, u)
+}
+
+func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
+       var p models.Project
+       if err := decodeJSON(r, &p); err != nil {
+               writeError(w, http.StatusBadRequest, err)
+               return
+       }
+       created, err := s.svc.CreateProject(r.Context(), &p)
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusCreated, created)
+}
+
+func (s *Server) getProject(w http.ResponseWriter, r *http.Request) {
+       p, err := s.svc.GetProject(r.Context(), r.PathValue("id"))
+       if err != nil {
+               writeServiceError(w, err)
+               return
+       }
+       writeJSON(w, http.StatusOK, p)
+}
+
+// LoggingMiddleware logs every request once it completes.
+func LoggingMiddleware(next http.Handler) http.Handler {
+       return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+               start := time.Now()
+               rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
+               next.ServeHTTP(rw, r)
+               slog.Info("http request",
+                       "method", r.Method,
+                       "path", r.URL.Path,
+                       "status", rw.status,
+                       "duration", time.Since(start).String(),
+               )
+       })
+}
+
+type statusRecorder struct {
+       http.ResponseWriter
+       status int
+}
+
+func (r *statusRecorder) WriteHeader(code int) {
+       r.status = code
+       r.ResponseWriter.WriteHeader(code)
+}
+
+func decodeJSON(r *http.Request, dst any) error {
+       dec := json.NewDecoder(r.Body)
+       dec.DisallowUnknownFields()
+       return dec.Decode(dst)
+}
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+       w.Header().Set("Content-Type", "application/json")
+       w.WriteHeader(status)
+       if body == nil {
+               return
+       }
+       _ = json.NewEncoder(w).Encode(body)
+}
+
+func writeError(w http.ResponseWriter, status int, err error) {
+       writeJSON(w, status, map[string]string{"error": err.Error()})
+}
+
+func writeServiceError(w http.ResponseWriter, err error) {
+       switch {
+       case errors.Is(err, service.ErrNotFound):
+               writeError(w, http.StatusNotFound, err)
+       case errors.Is(err, service.ErrAlreadyExists):
+               writeError(w, http.StatusConflict, err)
+       case errors.Is(err, service.ErrInvalidInput):
+               writeError(w, http.StatusBadRequest, err)
+       default:
+               // Avoid leaking driver messages to clients; log the full error.
+               slog.Error("internal server error", "error", err.Error())
+               writeError(w, http.StatusInternalServerError, 
errors.New(strings.TrimSpace("internal server error")))
+       }
+}
diff --git a/internal/store/organization_store.go 
b/internal/store/organization_store.go
new file mode 100644
index 000000000..c606dcb5c
--- /dev/null
+++ b/internal/store/organization_store.go
@@ -0,0 +1,82 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package store
+
+import (
+       "context"
+       "database/sql"
+       "errors"
+
+       "github.com/jmoiron/sqlx"
+
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+type mysqlOrganizationStore struct {
+       db *sqlx.DB
+}
+
+// NewOrganizationStore returns a MySQL-backed OrganizationStore.
+func NewOrganizationStore(db *sqlx.DB) OrganizationStore {
+       return &mysqlOrganizationStore{db: db}
+}
+
+func (s *mysqlOrganizationStore) FindByID(ctx context.Context, id string) 
(*models.Organization, error) {
+       var o models.Organization
+       err := s.db.GetContext(ctx, &o,
+               `SELECT id, originated_id, name FROM organizations WHERE id = 
?`, id)
+       if err != nil {
+               if errors.Is(err, sql.ErrNoRows) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       return &o, nil
+}
+
+func (s *mysqlOrganizationStore) FindByOriginatedID(ctx context.Context, 
originatedID string) (*models.Organization, error) {
+       var o models.Organization
+       err := s.db.GetContext(ctx, &o,
+               `SELECT id, originated_id, name FROM organizations WHERE 
originated_id = ?`, originatedID)
+       if err != nil {
+               if errors.Is(err, sql.ErrNoRows) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       return &o, nil
+}
+
+func (s *mysqlOrganizationStore) Create(ctx context.Context, tx *sql.Tx, o 
*models.Organization) error {
+       _, err := tx.ExecContext(ctx,
+               `INSERT INTO organizations (id, originated_id, name) VALUES (?, 
?, ?)`,
+               o.ID, o.OriginatedID, o.Name)
+       return err
+}
+
+func (s *mysqlOrganizationStore) Update(ctx context.Context, tx *sql.Tx, o 
*models.Organization) error {
+       _, err := tx.ExecContext(ctx,
+               `UPDATE organizations SET originated_id = ?, name = ? WHERE id 
= ?`,
+               o.OriginatedID, o.Name, o.ID)
+       return err
+}
+
+func (s *mysqlOrganizationStore) Delete(ctx context.Context, tx *sql.Tx, id 
string) error {
+       _, err := tx.ExecContext(ctx, `DELETE FROM organizations WHERE id = ?`, 
id)
+       return err
+}
diff --git a/internal/store/project_store.go b/internal/store/project_store.go
new file mode 100644
index 000000000..fc447500a
--- /dev/null
+++ b/internal/store/project_store.go
@@ -0,0 +1,97 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package store
+
+import (
+       "context"
+       "database/sql"
+       "errors"
+
+       "github.com/jmoiron/sqlx"
+
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+type mysqlProjectStore struct {
+       db *sqlx.DB
+}
+
+// NewProjectStore returns a MySQL-backed ProjectStore.
+func NewProjectStore(db *sqlx.DB) ProjectStore {
+       return &mysqlProjectStore{db: db}
+}
+
+func (s *mysqlProjectStore) FindByID(ctx context.Context, id string) 
(*models.Project, error) {
+       var p models.Project
+       err := s.db.GetContext(ctx, &p,
+               `SELECT id, originated_id, title, origination, project_pi_id, 
created_time
+                FROM projects WHERE id = ?`, id)
+       if err != nil {
+               if errors.Is(err, sql.ErrNoRows) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       return &p, nil
+}
+
+func (s *mysqlProjectStore) FindByOriginatedID(ctx context.Context, 
originatedID string) (*models.Project, error) {
+       var p models.Project
+       err := s.db.GetContext(ctx, &p,
+               `SELECT id, originated_id, title, origination, project_pi_id, 
created_time
+                FROM projects WHERE originated_id = ?`, originatedID)
+       if err != nil {
+               if errors.Is(err, sql.ErrNoRows) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       return &p, nil
+}
+
+func (s *mysqlProjectStore) FindByPI(ctx context.Context, piUserID string) 
([]models.Project, error) {
+       var projects []models.Project
+       err := s.db.SelectContext(ctx, &projects,
+               `SELECT id, originated_id, title, origination, project_pi_id, 
created_time
+                FROM projects WHERE project_pi_id = ?`, piUserID)
+       if err != nil {
+               return nil, err
+       }
+       return projects, nil
+}
+
+func (s *mysqlProjectStore) Create(ctx context.Context, tx *sql.Tx, p 
*models.Project) error {
+       _, err := tx.ExecContext(ctx,
+               `INSERT INTO projects (id, originated_id, title, origination, 
project_pi_id, created_time)
+                VALUES (?, ?, ?, ?, ?, ?)`,
+               p.ID, p.OriginatedID, p.Title, p.Origination, p.ProjectPIID, 
p.CreatedTime)
+       return err
+}
+
+func (s *mysqlProjectStore) Update(ctx context.Context, tx *sql.Tx, p 
*models.Project) error {
+       _, err := tx.ExecContext(ctx,
+               `UPDATE projects SET originated_id = ?, title = ?, origination 
= ?, project_pi_id = ?
+                WHERE id = ?`,
+               p.OriginatedID, p.Title, p.Origination, p.ProjectPIID, p.ID)
+       return err
+}
+
+func (s *mysqlProjectStore) Delete(ctx context.Context, tx *sql.Tx, id string) 
error {
+       _, err := tx.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, id)
+       return err
+}
diff --git a/internal/store/store.go b/internal/store/store.go
new file mode 100644
index 000000000..f0b1e1206
--- /dev/null
+++ b/internal/store/store.go
@@ -0,0 +1,71 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package store
+
+import (
+       "context"
+       "database/sql"
+
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+// UserStore defines persistence operations for users.
+type UserStore interface {
+       // FindByID returns the user with the given ID, or nil if not found.
+       FindByID(ctx context.Context, id string) (*models.User, error)
+       // FindByEmail returns the user with the given email, or nil if not 
found.
+       FindByEmail(ctx context.Context, email string) (*models.User, error)
+       // FindByOrganization returns all users belonging to the given 
organization.
+       FindByOrganization(ctx context.Context, organizationID string) 
([]models.User, error)
+       // Create inserts a new user within the provided transaction.
+       Create(ctx context.Context, tx *sql.Tx, u *models.User) error
+       // Update replaces mutable fields of an existing user within the 
provided transaction.
+       Update(ctx context.Context, tx *sql.Tx, u *models.User) error
+       // Delete removes a user by ID within the provided transaction.
+       Delete(ctx context.Context, tx *sql.Tx, id string) error
+}
+
+// OrganizationStore defines persistence operations for organizations.
+type OrganizationStore interface {
+       // FindByID returns the organization with the given ID, or nil if not 
found.
+       FindByID(ctx context.Context, id string) (*models.Organization, error)
+       // FindByOriginatedID returns the organization matching the external 
originated ID, or nil if not found.
+       FindByOriginatedID(ctx context.Context, originatedID string) 
(*models.Organization, error)
+       // Create inserts a new organization within the provided transaction.
+       Create(ctx context.Context, tx *sql.Tx, o *models.Organization) error
+       // Update replaces mutable fields of an existing organization within 
the provided transaction.
+       Update(ctx context.Context, tx *sql.Tx, o *models.Organization) error
+       // Delete removes an organization by ID within the provided transaction.
+       Delete(ctx context.Context, tx *sql.Tx, id string) error
+}
+
+// ProjectStore defines persistence operations for projects.
+type ProjectStore interface {
+       // FindByID returns the project with the given ID, or nil if not found.
+       FindByID(ctx context.Context, id string) (*models.Project, error)
+       // FindByOriginatedID returns the project matching the external 
originated ID, or nil if not found.
+       FindByOriginatedID(ctx context.Context, originatedID string) 
(*models.Project, error)
+       // FindByPI returns all projects whose PI matches the given user ID.
+       FindByPI(ctx context.Context, piUserID string) ([]models.Project, error)
+       // Create inserts a new project within the provided transaction.
+       Create(ctx context.Context, tx *sql.Tx, p *models.Project) error
+       // Update replaces mutable fields of an existing project within the 
provided transaction.
+       Update(ctx context.Context, tx *sql.Tx, p *models.Project) error
+       // Delete removes a project by ID within the provided transaction.
+       Delete(ctx context.Context, tx *sql.Tx, id string) error
+}
diff --git a/internal/store/user_store.go b/internal/store/user_store.go
new file mode 100644
index 000000000..eda2b749f
--- /dev/null
+++ b/internal/store/user_store.go
@@ -0,0 +1,97 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package store
+
+import (
+       "context"
+       "database/sql"
+       "errors"
+
+       "github.com/jmoiron/sqlx"
+
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+type mysqlUserStore struct {
+       db *sqlx.DB
+}
+
+// NewUserStore returns a MySQL-backed UserStore.
+func NewUserStore(db *sqlx.DB) UserStore {
+       return &mysqlUserStore{db: db}
+}
+
+func (s *mysqlUserStore) FindByID(ctx context.Context, id string) 
(*models.User, error) {
+       var u models.User
+       err := s.db.GetContext(ctx, &u,
+               `SELECT id, organization_id, first_name, last_name, 
middle_name, email
+                FROM users WHERE id = ?`, id)
+       if err != nil {
+               if errors.Is(err, sql.ErrNoRows) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       return &u, nil
+}
+
+func (s *mysqlUserStore) FindByEmail(ctx context.Context, email string) 
(*models.User, error) {
+       var u models.User
+       err := s.db.GetContext(ctx, &u,
+               `SELECT id, organization_id, first_name, last_name, 
middle_name, email
+                FROM users WHERE email = ?`, email)
+       if err != nil {
+               if errors.Is(err, sql.ErrNoRows) {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       return &u, nil
+}
+
+func (s *mysqlUserStore) FindByOrganization(ctx context.Context, 
organizationID string) ([]models.User, error) {
+       var users []models.User
+       err := s.db.SelectContext(ctx, &users,
+               `SELECT id, organization_id, first_name, last_name, 
middle_name, email
+                FROM users WHERE organization_id = ?`, organizationID)
+       if err != nil {
+               return nil, err
+       }
+       return users, nil
+}
+
+func (s *mysqlUserStore) Create(ctx context.Context, tx *sql.Tx, u 
*models.User) error {
+       _, err := tx.ExecContext(ctx,
+               `INSERT INTO users (id, organization_id, first_name, last_name, 
middle_name, email)
+                VALUES (?, ?, ?, ?, ?, ?)`,
+               u.ID, u.OrganizationID, u.FirstName, u.LastName, u.MiddleName, 
u.Email)
+       return err
+}
+
+func (s *mysqlUserStore) Update(ctx context.Context, tx *sql.Tx, u 
*models.User) error {
+       _, err := tx.ExecContext(ctx,
+               `UPDATE users SET organization_id = ?, first_name = ?, 
last_name = ?, middle_name = ?, email = ?
+                WHERE id = ?`,
+               u.OrganizationID, u.FirstName, u.LastName, u.MiddleName, 
u.Email, u.ID)
+       return err
+}
+
+func (s *mysqlUserStore) Delete(ctx context.Context, tx *sql.Tx, id string) 
error {
+       _, err := tx.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
+       return err
+}
diff --git a/pkg/README.md b/pkg/README.md
new file mode 100644
index 000000000..093ae7bb1
--- /dev/null
+++ b/pkg/README.md
@@ -0,0 +1,120 @@
+# pkg
+
+Public Go libraries shared across this repository's modules. Following the
+[golang-standards/project-layout](https://github.com/golang-standards/project-layout)
+convention, only externally-usable code lives here; private implementation
+details live in the top-level [`/internal`](../internal/) tree.
+
+Module root: `github.com/apache/airavata-custos` (root `go.mod`).
+
+## Packages
+
+| Package | Import path | Purpose |
+|---------|-------------|---------|
+| `models` | `github.com/apache/airavata-custos/pkg/models` | Shared domain 
types (`User`, `Organization`, `Project`, allocations) |
+| `service` | `github.com/apache/airavata-custos/pkg/service` | **High-level 
API** for creating, reading, updating, and deleting entities |
+
+The supporting `internal/db` and `internal/store` packages are private to this
+module and are not importable from outside the repository.
+
+---
+
+## Requirements
+
+- Go 1.24+
+- MySQL 8+ or MariaDB 10.5+
+
+---
+
+## Quick Start
+
+Every service method takes a [`context.Context`](https://pkg.go.dev/context)
+as its first argument. The context carries cancellation, deadlines, and
+request-scoped values down into the database driver, so an in-flight query is
+aborted if the caller goes away or a deadline passes. You typically derive it
+from one of:
+
+- `context.Background()` — root context for `main`, init, tests, scripts.
+- `r.Context()` — inside an HTTP handler; cancelled when the client 
disconnects.
+- The `ctx` argument of a gRPC handler.
+- `context.WithTimeout(parent, d)` — adds a deadline; **always `defer 
cancel()`**.
+- `signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)` — cancelled on 
Ctrl-C / SIGTERM.
+
+Never pass `nil`; use `context.Background()` (or `context.TODO()`) if you have
+nothing better.
+
+```go
+import (
+    "context"
+    "time"
+
+    "github.com/apache/airavata-custos/pkg/models"
+    "github.com/apache/airavata-custos/pkg/service"
+)
+
+svc := service.New(database) // *sqlx.DB
+
+// Derive a context with a 10s budget for this call chain.
+ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+defer cancel()
+
+org, err := svc.CreateOrganization(ctx, &models.Organization{
+    Name:         "University of Example",
+    OriginatedID: "ACCESS-ORG-001",
+})
+
+user, err := svc.CreateUser(ctx, &models.User{
+    OrganizationID: org.ID,
+    FirstName:      "Ada",
+    LastName:       "Lovelace",
+    Email:          "[email protected]",
+})
+
+project, err := svc.CreateProject(ctx, &models.Project{
+    Title:        "Climate Simulation 2026",
+    Origination:  "ACCESS",
+    OriginatedID: "ACCESS-PRJ-9000",
+    ProjectPIID:  user.ID,
+})
+```
+
+- If `ID` is left empty the service generates a UUID.
+- If `CreatedTime` on a project is zero, the service sets it to 
`time.Now().UTC()`.
+- The populated entity is returned.
+
+## Read / Update / Delete
+
+```go
+org,  err := svc.GetOrganization(ctx, orgID)
+user, err := svc.GetUserByEmail(ctx, "[email protected]")
+proj, err := svc.GetProject(ctx, projID)
+
+users, err    := svc.ListUsersByOrganization(ctx, orgID)
+projects, err := svc.ListProjectsByPI(ctx, userID)
+
+err = svc.UpdateUser(ctx, user)
+err = svc.DeleteProject(ctx, projID)
+```
+
+## Errors
+
+| Sentinel | Returned when |
+|----------|---------------|
+| `service.ErrNotFound` | A `GetX` call finds no matching record |
+| `service.ErrAlreadyExists` | Duplicate email or duplicate `originated_id` |
+| `service.ErrInvalidInput` | Missing required field, or unknown FK reference |
+
+Use `errors.Is(err, service.ErrNotFound)` to check.
+
+## Schema
+
+Tables created by the embedded migrations in `/internal/db/migrations/`:
+
+```
+organizations
+users          (FK → organizations.id)
+projects       (FK → users.id  via project_pi_id)
+```
+
+`parseTime=true` must be set in the DSN so MySQL `TIMESTAMP` columns scan into
+`time.Time` correctly.
diff --git a/pkg/models/project.go b/pkg/models/project.go
index 99841b6f6..d6325959d 100644
--- a/pkg/models/project.go
+++ b/pkg/models/project.go
@@ -3,25 +3,25 @@ package models
 import "time"
 
 type Project struct {
-       ID           string    `json:"id"`
-       OriginatedID string    `json:"originated_id"` // The ID of the project 
in origination. For example: ACCESS Record ID.
-       Title        string    `json:"title"`
-       Origination  string    `json:"origination"` // ACCESS, NAIRR, XRASS, 
etc.
-       ProjectPIID  string    `json:"project_pi_id"`
-       CreatedTime  time.Time `json:"created_time"`
+       ID           string    `json:"id"            db:"id"`
+       OriginatedID string    `json:"originated_id" db:"originated_id"` // The 
ID of the project in origination. For example: ACCESS Record ID.
+       Title        string    `json:"title"         db:"title"`
+       Origination  string    `json:"origination"   db:"origination"` // 
ACCESS, NAIRR, XRASS, etc.
+       ProjectPIID  string    `json:"project_pi_id" db:"project_pi_id"`
+       CreatedTime  time.Time `json:"created_time"  db:"created_time"`
 }
 
 type Organization struct {
-       ID           string `json:"id"`
-       OriginatedID string `json:"originated_id"` // The ID of the 
organization in origination. For example: ACCESS Record ID.
-       Name         string `json:"name"`
+       ID           string `json:"id"            db:"id"`
+       OriginatedID string `json:"originated_id" db:"originated_id"` // The ID 
of the organization in origination. For example: ACCESS Record ID.
+       Name         string `json:"name"          db:"name"`
 }
 
 type User struct {
-       ID             string `json:"id"`
-       OrganizationID string `json:"organization_id"`
-       FirstName      string `json:"first_name"`
-       LastName       string `json:"last_name"`
-       MiddleName     string `json:"middle_name,omitempty"`
-       Email          string `json:"email"`
+       ID             string `json:"id"              db:"id"`
+       OrganizationID string `json:"organization_id" db:"organization_id"`
+       FirstName      string `json:"first_name"      db:"first_name"`
+       LastName       string `json:"last_name"       db:"last_name"`
+       MiddleName     string `json:"middle_name,omitempty" db:"middle_name"`
+       Email          string `json:"email"           db:"email"`
 }
diff --git a/pkg/service/errors.go b/pkg/service/errors.go
new file mode 100644
index 000000000..bf0011036
--- /dev/null
+++ b/pkg/service/errors.go
@@ -0,0 +1,34 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package service provides high-level operations on the core domain entities
+// (Organization, User, Project). It hides database transactions and other
+// persistence concerns from callers, so other modules can use simple
+// CreateX / GetX / UpdateX / DeleteX functions.
+package service
+
+import "errors"
+
+// ErrNotFound is returned when a requested record does not exist.
+var ErrNotFound = errors.New("record not found")
+
+// ErrAlreadyExists is returned when attempting to create a record that
+// conflicts with an existing one (e.g. duplicate email).
+var ErrAlreadyExists = errors.New("record already exists")
+
+// ErrInvalidInput is returned when required fields are missing or invalid.
+var ErrInvalidInput = errors.New("invalid input")
diff --git a/pkg/service/organization.go b/pkg/service/organization.go
new file mode 100644
index 000000000..bca060a06
--- /dev/null
+++ b/pkg/service/organization.go
@@ -0,0 +1,106 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package service
+
+import (
+       "context"
+       "database/sql"
+       "fmt"
+
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+// CreateOrganization persists a new organization. If org.ID is empty a new
+// UUID is generated. The (possibly populated) organization is returned.
+func (s *Service) CreateOrganization(ctx context.Context, org 
*models.Organization) (*models.Organization, error) {
+       if org == nil {
+               return nil, fmt.Errorf("%w: organization is nil", 
ErrInvalidInput)
+       }
+       if org.Name == "" {
+               return nil, fmt.Errorf("%w: organization name is required", 
ErrInvalidInput)
+       }
+       if org.ID == "" {
+               org.ID = newID()
+       }
+
+       if org.OriginatedID != "" {
+               if existing, err := s.orgs.FindByOriginatedID(ctx, 
org.OriginatedID); err != nil {
+                       return nil, fmt.Errorf("lookup organization by 
originated id: %w", err)
+               } else if existing != nil {
+                       return nil, fmt.Errorf("%w: organization with 
originated_id %q", ErrAlreadyExists, org.OriginatedID)
+               }
+       }
+
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.orgs.Create(ctx, tx, org)
+       }); err != nil {
+               return nil, fmt.Errorf("create organization: %w", err)
+       }
+       return org, nil
+}
+
+// GetOrganization retrieves an organization by its ID. Returns ErrNotFound
+// when no organization matches.
+func (s *Service) GetOrganization(ctx context.Context, id string) 
(*models.Organization, error) {
+       org, err := s.orgs.FindByID(ctx, id)
+       if err != nil {
+               return nil, fmt.Errorf("get organization: %w", err)
+       }
+       if org == nil {
+               return nil, ErrNotFound
+       }
+       return org, nil
+}
+
+// GetOrganizationByOriginatedID retrieves an organization by its external 
originated ID.
+func (s *Service) GetOrganizationByOriginatedID(ctx context.Context, 
originatedID string) (*models.Organization, error) {
+       org, err := s.orgs.FindByOriginatedID(ctx, originatedID)
+       if err != nil {
+               return nil, fmt.Errorf("get organization by originated id: %w", 
err)
+       }
+       if org == nil {
+               return nil, ErrNotFound
+       }
+       return org, nil
+}
+
+// UpdateOrganization persists changes to an existing organization.
+func (s *Service) UpdateOrganization(ctx context.Context, org 
*models.Organization) error {
+       if org == nil || org.ID == "" {
+               return fmt.Errorf("%w: organization id is required", 
ErrInvalidInput)
+       }
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.orgs.Update(ctx, tx, org)
+       }); err != nil {
+               return fmt.Errorf("update organization: %w", err)
+       }
+       return nil
+}
+
+// DeleteOrganization removes an organization by ID.
+func (s *Service) DeleteOrganization(ctx context.Context, id string) error {
+       if id == "" {
+               return fmt.Errorf("%w: organization id is required", 
ErrInvalidInput)
+       }
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.orgs.Delete(ctx, tx, id)
+       }); err != nil {
+               return fmt.Errorf("delete organization: %w", err)
+       }
+       return nil
+}
diff --git a/pkg/service/project.go b/pkg/service/project.go
new file mode 100644
index 000000000..f503a537d
--- /dev/null
+++ b/pkg/service/project.go
@@ -0,0 +1,127 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package service
+
+import (
+       "context"
+       "database/sql"
+       "fmt"
+
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+// CreateProject persists a new project. If project.ID is empty a new UUID is
+// generated. The referenced PI user must already exist.
+func (s *Service) CreateProject(ctx context.Context, project *models.Project) 
(*models.Project, error) {
+       if project == nil {
+               return nil, fmt.Errorf("%w: project is nil", ErrInvalidInput)
+       }
+       if project.Title == "" {
+               return nil, fmt.Errorf("%w: project title is required", 
ErrInvalidInput)
+       }
+       if project.ProjectPIID == "" {
+               return nil, fmt.Errorf("%w: project_pi_id is required", 
ErrInvalidInput)
+       }
+
+       if pi, err := s.users.FindByID(ctx, project.ProjectPIID); err != nil {
+               return nil, fmt.Errorf("verify project PI: %w", err)
+       } else if pi == nil {
+               return nil, fmt.Errorf("%w: PI user %q does not exist", 
ErrInvalidInput, project.ProjectPIID)
+       }
+
+       if project.OriginatedID != "" {
+               if existing, err := s.projs.FindByOriginatedID(ctx, 
project.OriginatedID); err != nil {
+                       return nil, fmt.Errorf("lookup project by originated 
id: %w", err)
+               } else if existing != nil {
+                       return nil, fmt.Errorf("%w: project with originated_id 
%q", ErrAlreadyExists, project.OriginatedID)
+               }
+       }
+
+       if project.ID == "" {
+               project.ID = newID()
+       }
+       if project.CreatedTime.IsZero() {
+               project.CreatedTime = nowUTC()
+       }
+
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.projs.Create(ctx, tx, project)
+       }); err != nil {
+               return nil, fmt.Errorf("create project: %w", err)
+       }
+       return project, nil
+}
+
+// GetProject retrieves a project by ID. Returns ErrNotFound when no project 
matches.
+func (s *Service) GetProject(ctx context.Context, id string) (*models.Project, 
error) {
+       p, err := s.projs.FindByID(ctx, id)
+       if err != nil {
+               return nil, fmt.Errorf("get project: %w", err)
+       }
+       if p == nil {
+               return nil, ErrNotFound
+       }
+       return p, nil
+}
+
+// GetProjectByOriginatedID retrieves a project by its external originated ID.
+func (s *Service) GetProjectByOriginatedID(ctx context.Context, originatedID 
string) (*models.Project, error) {
+       p, err := s.projs.FindByOriginatedID(ctx, originatedID)
+       if err != nil {
+               return nil, fmt.Errorf("get project by originated id: %w", err)
+       }
+       if p == nil {
+               return nil, ErrNotFound
+       }
+       return p, nil
+}
+
+// ListProjectsByPI returns all projects whose PI matches the given user ID.
+func (s *Service) ListProjectsByPI(ctx context.Context, piUserID string) 
([]models.Project, error) {
+       projects, err := s.projs.FindByPI(ctx, piUserID)
+       if err != nil {
+               return nil, fmt.Errorf("list projects by PI: %w", err)
+       }
+       return projects, nil
+}
+
+// UpdateProject persists changes to an existing project.
+func (s *Service) UpdateProject(ctx context.Context, project *models.Project) 
error {
+       if project == nil || project.ID == "" {
+               return fmt.Errorf("%w: project id is required", ErrInvalidInput)
+       }
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.projs.Update(ctx, tx, project)
+       }); err != nil {
+               return fmt.Errorf("update project: %w", err)
+       }
+       return nil
+}
+
+// DeleteProject removes a project by ID.
+func (s *Service) DeleteProject(ctx context.Context, id string) error {
+       if id == "" {
+               return fmt.Errorf("%w: project id is required", ErrInvalidInput)
+       }
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.projs.Delete(ctx, tx, id)
+       }); err != nil {
+               return fmt.Errorf("delete project: %w", err)
+       }
+       return nil
+}
diff --git a/pkg/service/service.go b/pkg/service/service.go
new file mode 100644
index 000000000..7250e493f
--- /dev/null
+++ b/pkg/service/service.go
@@ -0,0 +1,73 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package service
+
+import (
+       "context"
+       "database/sql"
+       "time"
+
+       "github.com/google/uuid"
+       "github.com/jmoiron/sqlx"
+
+       "github.com/apache/airavata-custos/internal/db"
+       "github.com/apache/airavata-custos/internal/store"
+)
+
+// Service is a high-level façade over the underlying stores. It wraps each
+// mutating operation in a transaction so callers do not need to manage
+// *sql.Tx themselves.
+type Service struct {
+       db    *sqlx.DB
+       orgs  store.OrganizationStore
+       users store.UserStore
+       projs store.ProjectStore
+}
+
+// New constructs a Service backed by the supplied database handle.
+// Stores are instantiated internally using the default MySQL implementations.
+func New(database *sqlx.DB) *Service {
+       return &Service{
+               db:    database,
+               orgs:  store.NewOrganizationStore(database),
+               users: store.NewUserStore(database),
+               projs: store.NewProjectStore(database),
+       }
+}
+
+// NewWithStores constructs a Service from explicit stores. Useful for tests
+// within this module — stores are an internal type and cannot be supplied by
+// external callers.
+func NewWithStores(database *sqlx.DB, orgs store.OrganizationStore, users 
store.UserStore, projs store.ProjectStore) *Service {
+       return &Service{db: database, orgs: orgs, users: users, projs: projs}
+}
+
+// inTx runs fn inside a database transaction managed by the Service.
+func (s *Service) inTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
+       return db.TxFn(ctx, s.db, fn)
+}
+
+// newID returns a new identifier for a freshly created entity.
+func newID() string {
+       return uuid.NewString()
+}
+
+// nowUTC returns the current time in UTC.
+func nowUTC() time.Time {
+       return time.Now().UTC()
+}
diff --git a/pkg/service/user.go b/pkg/service/user.go
new file mode 100644
index 000000000..da809881c
--- /dev/null
+++ b/pkg/service/user.go
@@ -0,0 +1,122 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package service
+
+import (
+       "context"
+       "database/sql"
+       "fmt"
+
+       "github.com/apache/airavata-custos/pkg/models"
+)
+
+// CreateUser persists a new user. If user.ID is empty a new UUID is generated.
+// The referenced organization must already exist.
+func (s *Service) CreateUser(ctx context.Context, user *models.User) 
(*models.User, error) {
+       if user == nil {
+               return nil, fmt.Errorf("%w: user is nil", ErrInvalidInput)
+       }
+       if user.Email == "" {
+               return nil, fmt.Errorf("%w: user email is required", 
ErrInvalidInput)
+       }
+       if user.OrganizationID == "" {
+               return nil, fmt.Errorf("%w: user organization_id is required", 
ErrInvalidInput)
+       }
+
+       if org, err := s.orgs.FindByID(ctx, user.OrganizationID); err != nil {
+               return nil, fmt.Errorf("verify organization: %w", err)
+       } else if org == nil {
+               return nil, fmt.Errorf("%w: organization %q does not exist", 
ErrInvalidInput, user.OrganizationID)
+       }
+
+       if existing, err := s.users.FindByEmail(ctx, user.Email); err != nil {
+               return nil, fmt.Errorf("lookup user by email: %w", err)
+       } else if existing != nil {
+               return nil, fmt.Errorf("%w: user with email %q", 
ErrAlreadyExists, user.Email)
+       }
+
+       if user.ID == "" {
+               user.ID = newID()
+       }
+
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.users.Create(ctx, tx, user)
+       }); err != nil {
+               return nil, fmt.Errorf("create user: %w", err)
+       }
+       return user, nil
+}
+
+// GetUser retrieves a user by ID. Returns ErrNotFound when no user matches.
+func (s *Service) GetUser(ctx context.Context, id string) (*models.User, 
error) {
+       u, err := s.users.FindByID(ctx, id)
+       if err != nil {
+               return nil, fmt.Errorf("get user: %w", err)
+       }
+       if u == nil {
+               return nil, ErrNotFound
+       }
+       return u, nil
+}
+
+// GetUserByEmail retrieves a user by email.
+func (s *Service) GetUserByEmail(ctx context.Context, email string) 
(*models.User, error) {
+       u, err := s.users.FindByEmail(ctx, email)
+       if err != nil {
+               return nil, fmt.Errorf("get user by email: %w", err)
+       }
+       if u == nil {
+               return nil, ErrNotFound
+       }
+       return u, nil
+}
+
+// ListUsersByOrganization returns all users belonging to an organization.
+func (s *Service) ListUsersByOrganization(ctx context.Context, organizationID 
string) ([]models.User, error) {
+       users, err := s.users.FindByOrganization(ctx, organizationID)
+       if err != nil {
+               return nil, fmt.Errorf("list users by organization: %w", err)
+       }
+       return users, nil
+}
+
+// UpdateUser persists changes to an existing user.
+func (s *Service) UpdateUser(ctx context.Context, user *models.User) error {
+       if user == nil || user.ID == "" {
+               return fmt.Errorf("%w: user id is required", ErrInvalidInput)
+       }
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.users.Update(ctx, tx, user)
+       }); err != nil {
+               return fmt.Errorf("update user: %w", err)
+       }
+       return nil
+}
+
+// DeleteUser removes a user by ID.
+func (s *Service) DeleteUser(ctx context.Context, id string) error {
+       if id == "" {
+               return fmt.Errorf("%w: user id is required", ErrInvalidInput)
+       }
+       if err := s.inTx(ctx, func(tx *sql.Tx) error {
+               return s.users.Delete(ctx, tx, id)
+       }); err != nil {
+               return fmt.Errorf("delete user: %w", err)
+       }
+       return nil
+}

Reply via email to