コンテンツにスキップ
BlackOps1.xは試験的なバージョンです。Production Readyは2.xを予定しています。Releases
BlackOps
Esc
navigateopen⌘Jpreview
このページの内容

First Operation

Operation生成からHTTP 202受付、Status、Worker、Typed Outcome取得までを完走する。

BlackOps CLIからBilling/CreateInvoiceの骨格を生成し、HTTPで受け付けるDeferred Operationへ仕上げます。

このPageはInstallのStable HTTP 200確認、またはQuickstart and SkeletonのRepository main Preview準備が完了したProject Rootを前提にします。Step 1〜3(Generator、Value、Outcome)に加えて、#[Route]、Deferred実行、WorkerはStable 1.1.0でも利用できます。main Preview限定なのは、Step 4の#[Authorize]とSample Token Header、Step 5のFrontend検証、Step 6・7のStatus Resourceです。Stableではこれらを除いて実行してください。Container CLIを使う場合はdocker compose run --rm appを各Commandの前へ付け、Hostのphp blackopsと混在させません。

起動、Migration、Buildで詰まった場合はTroubleshootingを参照してください。

Release: このTutorialはExperimental Stable 1.1.0のProject Root blackopsmake:operation、宣言的Validation Attributeを使用します。#[Authorize]とSample Token Authentication、Frontend/Status Resourceはmainの未Release Surfaceであり、Repository Quickstart向けです。

1. Generatorから始める

Project Rootで実行します。

docker compose run --rm app php blackops make:operation Billing/CreateInvoice --type=billing.invoice.create
Created: app/Feature/Billing/CreateInvoice/CreateInvoice.php
Created: app/Feature/Billing/CreateInvoice/CreateInvoiceValue.php
Created: app/Feature/Billing/CreateInvoice/CreateInvoiceOutcome.php

Generatorが作るのはBuild可能なOperation、Value、Outcomeの3 Fileです。既存Fileを上書きせず、Route、Execution Strategy、PropertyはApplicationの判断として追加しません。ここから先の3 Fileは利用者が編集する完成形です。

2. ValueへInputとValidationを書く

app/Feature/Billing/CreateInvoice/CreateInvoiceValue.phpを置き換えます。

<?php

declare(strict_types=1);

namespace App\Feature\Billing\CreateInvoice;

use BlackOps\Core\Attribute\Sensitive;
use BlackOps\Core\Attribute\SensitiveMode;
use BlackOps\Core\OperationValue;
use BlackOps\Core\Validation\Attribute\Email;
use BlackOps\Core\Validation\Attribute\Length;
use BlackOps\Core\Validation\Attribute\NotBlank;
use BlackOps\Core\Validation\Attribute\Range;
use SensitiveParameter;

final readonly class CreateInvoiceValue implements OperationValue
{
    public function __construct(
        #[NotBlank]
        #[Length(min: 3, max: 80)]
        public string $customerName,
        #[Email]
        public string $email,
        #[Range(min: 1, max: 100)]
        public int $quantity,
        #[Sensitive(SensitiveMode::Mask)]
        #[SensitiveParameter]
        #[NotBlank]
        public string $billingReference,
    ) {}
}

PHP TypeはHTTP Bindingの型境界です。NotBlankLengthEmailRangeはBinding後のValueを検証します。billingReferenceは業務上のSensitive値であり、SensitiveはObserved JournalでMaskし、SensitiveParameterはStack Trace上の引数をRedactします。認証CredentialはこのValueへ追加せずHeader Authenticationへ任せます。

3. Outcomeを書く

app/Feature/Billing/CreateInvoice/CreateInvoiceOutcome.phpを置き換えます。

<?php

declare(strict_types=1);

namespace App\Feature\Billing\CreateInvoice;

use BlackOps\Core\Outcome;

final readonly class CreateInvoiceOutcome implements Outcome
{
    public function __construct(
        public string $invoiceId,
        public string $customerName,
        public int $quantity,
    ) {}
}

Handlerの正常系Return Typeは具象Outcomeだけです。Rejected ResultをReturn Typeへ混ぜず、予期された拒否はFrameworkのExceptionへ委ねます。

4. RouteとDeferred Strategyを書く

app/Feature/Billing/CreateInvoice/CreateInvoice.phpを置き換えます。

<?php

declare(strict_types=1);

namespace App\Feature\Billing\CreateInvoice;

use App\Security\SampleUserAuthorizationPolicy;
use BlackOps\Core\Attribute\Authorize;
use BlackOps\Core\Attribute\Deferred;
use BlackOps\Core\Attribute\OperationType;
use BlackOps\Core\ExecutionContext;
use BlackOps\Core\Operation;
use BlackOps\Http\Attribute\Route;

#[Route(method: 'POST', path: '/invoices')]
#[OperationType('billing.invoice.create')]
#[Deferred]
#[Authorize(SampleUserAuthorizationPolicy::class)]
final readonly class CreateInvoice implements Operation
{
    public function handle(CreateInvoiceValue $value, ExecutionContext $context): CreateInvoiceOutcome
    {
        return new CreateInvoiceOutcome(
            invoiceId: $context->operationId()->toString(),
            customerName: $value->customerName,
            quantity: $value->quantity,
        );
    }
}

main PreviewではCanonical Attributeの#[Deferred]を使います。Stable 1.1.0で同じDeferred実行を指定する場合は、use BlackOps\Core\Attribute\ExecuteWith;use BlackOps\Core\Execution\Deferred;を追加し、#[ExecuteWith(Deferred::class)]へ置き換えてください。Stableへ#[Deferred]を案内しないことが重要です。

Value型とOutcome型はhandle() Signatureから推論されます。AcceptsReturns、Handler用Interfaceは不要です。ExecutionContextが不要なら第二引数ごと省略できます。

5. BuildしてRouteを有効にする

docker compose run --rm app composer dump-autoload
docker compose run --rm app php blackops build:compile
docker compose run --rm app php blackops frontend:generate
docker compose run --rm app php blackops frontend:check
pnpm test
docker compose up -d
Build artifacts written.

BuildはOperation Signature、Metadata、Routeを検証し、ManifestとDI Containerを生成します。RuntimeはSource DiscoveryへFallbackしないため、Source変更後は明示的に再Buildします。

6. HTTPで受け付ける

curl -sS -X POST -H 'Content-Type: application/json' \
  -H 'X-Sample-Token: local-example' \
  -d '{"customerName":"Acme","email":"billing@example.com","quantity":2,"billingReference":"PO-2026-001"}' \
  http://127.0.0.1:8080/invoices
{"status":"accepted","operationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687697","acceptedAt":"2026-07-14T01:23:45.678901Z"}

HTTP 202はDurable受付の結果です。HandlerはHTTP Process内でまだ実行されません。operationIdacceptedAtは実行ごとに変わります。

同じOperation IDをPublic Status Resourceへ渡すと、Worker未起動中のacceptedを確認できます。

OPERATION_ID='019f32ab-2be0-7b38-a0a7-1ab2f9687697'
curl -sS -H 'X-Sample-Token: local-example' \
  "http://127.0.0.1:8080/operations/${OPERATION_ID}"
{"schemaVersion":1,"operationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687697","operationType":"billing.invoice.create","state":"accepted"}

不正なEmailを送るとHTTP 422になり、HandlerもDeferred受付も実行されません。

curl -sS -X POST -H 'Content-Type: application/json' \
  -H 'X-Sample-Token: local-example' \
  -d '{"customerName":"Acme","email":"invalid","quantity":2,"billingReference":"PO-2026-001"}' \
  http://127.0.0.1:8080/invoices
{"status":"rejected","operationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687698","category":"validation","code":"validation.failed","violations":[{"field":"email","rule":"email","code":"validation.email"}]}

7. Workerで実行する

docker compose run --rm app php blackops worker:run --iterations=1 --idle-sleep-milliseconds=1
Worker stopped. Processed claims: 1

var/log/journal.jsonlはHTTP ProcessのObserved Projectionです。上で実行した422 RequestはHTTP内でoperation.receivedからoperation.rejectedまで進むため、Validation ResponseのOperation IDで安全なProjectionを確認できます。billingReferenceとActor IDはMaskされ、Header Credentialは保存されません。

VALIDATION_OPERATION_ID='019f32ab-2be0-7b38-a0a7-1ab2f9687698'
grep "$VALIDATION_OPERATION_ID" var/log/journal.jsonl \
  | grep -E '"event":"operation.(received|rejected)"'

次はHTTP Observed Encoder Shapeの抜粋です。Operation IDとoccurredAtは実行ごとに変わります。

{"schemaVersion":1,"kind":"journal","event":"operation.received","occurredAt":"2026-07-14T01:24:45.678901Z","sequence":1,"operation":{"id":"019f32ab-2be0-7b38-a0a7-1ab2f9687698","type":"billing.invoice.create","schemaVersion":1,"strategy":"deferred","correlationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687698","causationId":null,"actors":{"origin":{"id":"[masked]","type":"user"},"authorization":{"id":"[masked]","type":"user"},"execution":{"id":"[masked]","type":"user"}}},"attempt":null,"data":{"value":{"customerName":"Acme","email":"invalid","quantity":2,"billingReference":"[masked]"}}}
{"schemaVersion":1,"kind":"journal","event":"operation.rejected","occurredAt":"2026-07-14T01:24:45.679012Z","sequence":2,"operation":{"id":"019f32ab-2be0-7b38-a0a7-1ab2f9687698","type":"billing.invoice.create","schemaVersion":1,"strategy":"deferred","correlationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687698","causationId":null,"actors":{"origin":{"id":"[masked]","type":"user"},"authorization":{"id":"[masked]","type":"user"},"execution":{"id":"[masked]","type":"user"}}},"attempt":null,"data":{"reason":{"category":"validation","code":"validation.failed","violations":[{"field":"email","rule":"email","code":"validation.email"}]}}}

Worker完了後は、同じPublic Status ResourceからTyped Outcomeを取得できます。

curl -sS -H 'X-Sample-Token: local-example' \
  "http://127.0.0.1:8080/operations/${OPERATION_ID}"
{"schemaVersion":1,"operationId":"019f32ab-2be0-7b38-a0a7-1ab2f9687697","operationType":"billing.invoice.create","state":"completed","outcome":{"invoiceId":"019f32ab-2be0-7b38-a0a7-1ab2f9687697","customerName":"Acme","quantity":2}}

Frontendでは生成したOperation Objectから同じ経路を型付きで使います。.fetch()は受付だけ、.status()は一回だけ取得し、.wait()はAbort可能な有限待機です。

import { createBlackOpsClient } from './resources/js/blackops';

const blackops = createBlackOpsClient({
  baseUrl: 'http://127.0.0.1:8080',
  fetch: event.fetch,
  headers: { 'X-Sample-Token': 'local-example' },
});
const accepted = await blackops.CreateInvoice.fetch({
  customerName: 'Acme',
  email: 'billing@example.com',
  quantity: 2,
  billingReference: 'PO-2026-001',
}, { idempotencyKey: 'invoice-po-2026-001' });

if (accepted.ok && accepted.kind === 'accepted') {
  const current = await blackops.CreateInvoice.status(accepted.data.operationId);
  const controller = new AbortController();
  const terminal = await blackops.CreateInvoice.wait(accepted.data.operationId, {
    signal: controller.signal,
    maxWaitMilliseconds: 15_000,
  });

  if (terminal.ok && terminal.kind === 'completed') {
    terminal.data.outcome.invoiceId;
    terminal.data.outcome.customerName;
    terminal.data.outcome.quantity;
  }

  void current;
}

Canonical PostgreSQL Journalは監査と再現の正本としてActor IDとRaw Valueを保持します。Database暗号化、Access Control、RetentionはApplication/運用の責務です。PHP AdapterからOutcomeだけを読む低Level ContractはPublic OutcomeReaderです。Pending、Terminal、Expiredを区別するときはOutcomeのStatus Queryを主経路にしてください。

作業後はRuntimeを停止します。

docker compose down

Getting Startedを続ける場合はDirectoryでApplicationが所有する配置を確認してください。宣言的Rule、Cross-field Validation、Business Rejectionの詳細はValue and Validationを参照します。